diff --git a/api/entrypoints/routers.py b/api/entrypoints/routers.py index 2e1bfbaa4c..06b5191d3b 100644 --- a/api/entrypoints/routers.py +++ b/api/entrypoints/routers.py @@ -137,10 +137,6 @@ from oss.src.core.ai_services.service import AIServicesService from oss.src.apis.fastapi.ai_services.router import AIServicesRouter -from oss.src.dbs.postgres.sessions.states.dbes import SessionStateDBE # noqa: F401 -from oss.src.dbs.postgres.sessions.states.dao import SessionStatesDAO -from oss.src.core.sessions.states.service import SessionStatesService - from oss.src.core.accounts.service import PlatformAdminAccountsService from oss.src.apis.fastapi.accounts.router import PlatformAdminAccountsRouter from oss.src.dbs.postgres.gateway.connections.dao import ConnectionsDAO @@ -178,10 +174,15 @@ from oss.src.tasks.asyncio.sessions.orphan_sweep import orphan_sweep_loop from oss.src.dbs.redis.shared.engine import get_lock_engine +from oss.src.dbs.postgres.sessions.turns.dbes import SessionTurnDBE # noqa: F401 +from oss.src.dbs.postgres.sessions.turns.dao import SessionTurnsDAO +from oss.src.core.sessions.turns.service import SessionTurnsService + # Interactions from oss.src.dbs.postgres.sessions.interactions.dbes import SessionInteractionDBE # noqa: F401 from oss.src.dbs.postgres.sessions.interactions.dao import SessionInteractionsDAO from oss.src.core.sessions.interactions.service import SessionInteractionsService +from oss.src.core.sessions.service import SessionsService from oss.src.tasks.asyncio.sessions.interactions_dispatcher import ( InteractionsDispatcher, ) @@ -549,6 +550,7 @@ async def lifespan(*args, **kwargs): evaluations_dao = EvaluationsDAO(engine=_transactions_engine) folders_dao = FoldersDAO(engine=_transactions_engine) session_streams_dao = SessionStreamsDAO(engine=_transactions_engine) +session_turns_dao = SessionTurnsDAO(engine=_transactions_engine) connections_dao = ConnectionsDAO(engine=_transactions_engine) mounts_dao = MountsDAO(engine=_transactions_engine) @@ -616,6 +618,10 @@ async def lifespan(*args, **kwargs): lock_engine=_lock_engine, ) +session_turns_service = SessionTurnsService( + turns_dao=session_turns_dao, +) + workflows_service = WorkflowsService( workflows_dao=workflows_dao, static_catalog=StaticWorkflowCatalog(), @@ -1026,22 +1032,26 @@ async def _dispatch_detached_run(*, project_id, user_id, request) -> str: ai_services_service=ai_services_service, ) -# SESSION STATES --------------------------------------------------------------- +# SESSIONS --------------------------------------------------------------------- +# Session header rename (name/description) lives on the streams router +# (PUT /sessions/streams/header) via streams_service.set_header. -session_states_dao = SessionStatesDAO(engine=_transactions_engine) - -session_states_service = SessionStatesService( - session_states_dao=session_states_dao, +sessions_service = SessionsService( + streams_service=session_streams_service, + turns_service=session_turns_service, + interactions_service=interactions_service, + mounts_service=mounts_service, ) sessions = SessionsRouter( streams_service=session_streams_service, - states_service=session_states_service, records_service=records_service, interactions_service=interactions_service, workflows_service=workflows_service, session_mounts_service=session_mounts_service, mounts_service=mounts_service, + turns_service=session_turns_service, + sessions_service=sessions_service, respond_task=_interactions_worker.respond_interaction, ) @@ -1479,6 +1489,12 @@ async def _dispatch_detached_run(*, project_id, user_id, request) -> str: tags=["Sessions"], ) +app.include_router( + router=sessions.turns.router, + prefix="/sessions/turns", + tags=["Sessions"], +) + app.include_router( router=platform_admin_accounts.router, prefix="/admin", @@ -1486,8 +1502,7 @@ async def _dispatch_detached_run(*, project_id, user_id, request) -> str: ) app.include_router( - router=sessions.states.router, - prefix="/sessions", + router=sessions.root.router, tags=["Sessions"], ) diff --git a/api/oss/databases/postgres/migrations/core/env.py b/api/oss/databases/postgres/migrations/core/env.py index 93511ab358..219ebdb21d 100644 --- a/api/oss/databases/postgres/migrations/core/env.py +++ b/api/oss/databases/postgres/migrations/core/env.py @@ -22,7 +22,6 @@ import oss.src.dbs.postgres.users.dbes # noqa: F401 import oss.src.dbs.postgres.workflows.dbes # noqa: F401 import oss.src.dbs.postgres.webhooks.dbes # noqa: F401 -import oss.src.dbs.postgres.sessions.states.dbes # noqa: F401 # this is the Alembic Config object, which provides diff --git a/api/oss/databases/postgres/migrations/core_oss/versions/oss000000014_add_session_turns.py b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000014_add_session_turns.py new file mode 100644 index 0000000000..a8ff6398eb --- /dev/null +++ b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000014_add_session_turns.py @@ -0,0 +1,99 @@ +"""add session_turns table + +Revision ID: oss000000014 +Revises: oss000000013 +Create Date: 2026-07-17 00:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "oss000000014" +down_revision: Union[str, None] = "oss000000013" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "session_turns", + sa.Column("id", sa.UUID(as_uuid=True), nullable=False), + sa.Column("project_id", sa.UUID(as_uuid=True), nullable=False), + sa.Column("session_id", sa.String(), nullable=False), + sa.Column("turn_id", sa.UUID(as_uuid=True), nullable=True), + sa.Column("stream_id", sa.UUID(as_uuid=True), nullable=False), + sa.Column("turn_index", sa.Integer(), nullable=False), + sa.Column("harness_kind", sa.String(), nullable=False), + sa.Column("agent_session_id", sa.String(), nullable=True), + sa.Column("sandbox_id", sa.String(), nullable=True), + sa.Column( + "references", + postgresql.JSONB(none_as_null=True), + nullable=True, + ), + sa.Column("trace_id", sa.UUID(as_uuid=True), nullable=True), + # OTel span id: 64-bit / 16 hex chars, NOT a UUID (128-bit / 32 hex). Text, not UUID. + sa.Column("span_id", sa.String(), nullable=True), + sa.Column("start_time", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column("end_time", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.func.current_timestamp(), + nullable=True, + ), + sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column("created_by_id", sa.UUID(as_uuid=True), nullable=True), + sa.Column("updated_by_id", sa.UUID(as_uuid=True), nullable=True), + sa.Column("deleted_by_id", sa.UUID(as_uuid=True), nullable=True), + sa.ForeignKeyConstraint( + ["project_id"], + ["projects.id"], + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["project_id", "stream_id"], + ["session_streams.project_id", "session_streams.id"], + ondelete="NO ACTION", + ), + sa.PrimaryKeyConstraint("project_id", "id"), + ) + op.create_index( + "ix_session_turns_project_id_session_id", + "session_turns", + ["project_id", "session_id"], + ) + op.create_index( + "ix_session_turns_project_id_session_id_turn_index", + "session_turns", + ["project_id", "session_id", "turn_index"], + unique=True, + ) + op.create_index( + "ix_session_turns_references", + "session_turns", + ["references"], + postgresql_using="gin", + postgresql_ops={"references": "jsonb_path_ops"}, + ) + + +def downgrade() -> None: + op.drop_index( + "ix_session_turns_references", + table_name="session_turns", + ) + op.drop_index( + "ix_session_turns_project_id_session_id_turn_index", + table_name="session_turns", + ) + op.drop_index( + "ix_session_turns_project_id_session_id", + table_name="session_turns", + ) + op.drop_table("session_turns") diff --git a/api/oss/databases/postgres/migrations/core_oss/versions/oss000000015_add_session_streams_header.py b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000015_add_session_streams_header.py new file mode 100644 index 0000000000..612d606a01 --- /dev/null +++ b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000015_add_session_streams_header.py @@ -0,0 +1,33 @@ +"""add name/description header to session_streams + +Revision ID: oss000000015 +Revises: oss000000014 +Create Date: 2026-07-17 00:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "oss000000015" +down_revision: Union[str, None] = "oss000000014" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "session_streams", + sa.Column("name", sa.String(), nullable=True), + ) + op.add_column( + "session_streams", + sa.Column("description", sa.String(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("session_streams", "description") + op.drop_column("session_streams", "name") diff --git a/api/oss/databases/postgres/migrations/core_oss/versions/oss000000016_add_mounts_agent_id.py b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000016_add_mounts_agent_id.py new file mode 100644 index 0000000000..8415b05eea --- /dev/null +++ b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000016_add_mounts_agent_id.py @@ -0,0 +1,59 @@ +"""add mounts agent_id column, index, and backfill + +Mirrors session_id: a bare, nullable varchar populated only for agent mounts +(session mounts stay agent_id-null), with a partial index for querying "mounts +for agent X" the same way session_id already supports "mounts for session X". + +Backfill: mounts are core DB, so a data migration is allowed here (unlike the +tracing DB). Agent-mount slugs are minted as +`__ag__agent____` (`mint_agent_slug`, +`core/mounts/service.py`) — the artifact id is the raw canonical (lowercase) +UUID, not a hash, so it is deterministically recoverable straight out of the +slug. Session-mount slugs (`__ag__session____`) hash +the session id, so no session_id backfill is possible or attempted here. + +Revision ID: oss000000016 +Revises: oss000000015 +Create Date: 2026-07-17 00:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "oss000000016" +down_revision: Union[str, None] = "oss000000015" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_AGENT_SLUG_PREFIX = "__ag__agent__" + + +def upgrade() -> None: + op.add_column("mounts", sa.Column("agent_id", sa.String(), nullable=True)) + + op.create_index( + "ix_mounts_project_id_agent_id", + "mounts", + ["project_id", "agent_id"], + unique=False, + postgresql_where=sa.text("agent_id IS NOT NULL"), + ) + + conn = op.get_bind() + conn.execute( + sa.text( + f""" + UPDATE mounts + SET agent_id = split_part(substr(slug, {len(_AGENT_SLUG_PREFIX) + 1}), '__', 1) + WHERE left(slug, {len(_AGENT_SLUG_PREFIX)}) = '{_AGENT_SLUG_PREFIX}' + """ + ) + ) + + +def downgrade() -> None: + op.drop_index("ix_mounts_project_id_agent_id", table_name="mounts") + op.drop_column("mounts", "agent_id") diff --git a/api/oss/databases/postgres/migrations/core_oss/versions/oss000000017_drop_session_states.py b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000017_drop_session_states.py new file mode 100644 index 0000000000..364befd38c --- /dev/null +++ b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000017_drop_session_states.py @@ -0,0 +1,61 @@ +"""drop session_states table + +Superseded by the session_streams header (name/description) — the /sessions/states/ +router now reads/writes the merged stream row via streams_service. The standalone +table, its DAO/service, and DBE are orphaned; drop the physical table. + +Revision ID: oss000000017 +Revises: oss000000016 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +revision: str = "oss000000017" +down_revision: Union[str, None] = "oss000000016" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.drop_table("session_states") + + +def downgrade() -> None: + op.create_table( + "session_states", + sa.Column("project_id", sa.UUID(), nullable=False), + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("session_id", sa.String(), nullable=False), + sa.Column("sandbox_id", sa.String(), nullable=True), + sa.Column( + "data", + postgresql.JSON(astext_type=sa.Text()), + nullable=True, + ), + sa.Column("flags", postgresql.JSONB(none_as_null=True), nullable=True), + sa.Column("tags", postgresql.JSONB(none_as_null=True), nullable=True), + sa.Column("meta", postgresql.JSON(none_as_null=True), nullable=True), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=True, + ), + sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column("created_by_id", sa.UUID(), nullable=True), + sa.Column("updated_by_id", sa.UUID(), nullable=True), + sa.Column("deleted_by_id", sa.UUID(), nullable=True), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("project_id", "id"), + sa.UniqueConstraint( + "project_id", + "session_id", + name="uq_session_states_project_session_id", + ), + ) diff --git a/api/oss/databases/postgres/migrations/tracing_oss/versions/oss000000003_add_span_identity_columns.py b/api/oss/databases/postgres/migrations/tracing_oss/versions/oss000000003_add_span_identity_columns.py new file mode 100644 index 0000000000..bf50595d81 --- /dev/null +++ b/api/oss/databases/postgres/migrations/tracing_oss/versions/oss000000003_add_span_identity_columns.py @@ -0,0 +1,39 @@ +"""add_span_identity_columns + +Revision ID: oss000000003 +Revises: oss000000002 +Create Date: 2026-07-17 12:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "oss000000003" +down_revision: Union[str, None] = "oss000000002" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("spans", sa.Column("session_id", sa.String(), nullable=True)) + op.add_column("spans", sa.Column("user_id", sa.String(), nullable=True)) + op.add_column("spans", sa.Column("agent_id", sa.String(), nullable=True)) + + op.create_index( + "ix_spans_project_id_session_id", + "spans", + ["project_id", "session_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("ix_spans_project_id_session_id", table_name="spans") + + op.drop_column("spans", "agent_id") + op.drop_column("spans", "user_id") + op.drop_column("spans", "session_id") diff --git a/api/oss/databases/postgres/migrations/tracing_oss/versions/oss000000004_add_records_turn_span.py b/api/oss/databases/postgres/migrations/tracing_oss/versions/oss000000004_add_records_turn_span.py new file mode 100644 index 0000000000..b6ae152f13 --- /dev/null +++ b/api/oss/databases/postgres/migrations/tracing_oss/versions/oss000000004_add_records_turn_span.py @@ -0,0 +1,40 @@ +"""add_records_turn_span + +Revision ID: oss000000004 +Revises: oss000000003 +Create Date: 2026-07-17 12:16:14.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "oss000000004" +down_revision: Union[str, None] = "oss000000003" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Plain columns, no FK (cross-DB: turns/spans live outside the tracing DB). Both + # nullable and forward-fill only — the tracing DB is never backfilled/data-migrated; + # existing rows carry no turn key to reconstruct one from, so they stay null. + op.add_column("records", sa.Column("turn_id", sa.String(), nullable=True)) + # OTel span id: 64-bit / 16 hex chars, NOT a UUID (128-bit / 32 hex). Text, not UUID. + op.add_column("records", sa.Column("span_id", sa.String(), nullable=True)) + + op.create_index( + "ix_records_project_id_session_id_turn_id", + "records", + ["project_id", "session_id", "turn_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("ix_records_project_id_session_id_turn_id", table_name="records") + op.drop_column("records", "span_id") + op.drop_column("records", "turn_id") diff --git a/api/oss/src/apis/fastapi/mounts/router.py b/api/oss/src/apis/fastapi/mounts/router.py index 7307b16a3f..f33d7b35d0 100644 --- a/api/oss/src/apis/fastapi/mounts/router.py +++ b/api/oss/src/apis/fastapi/mounts/router.py @@ -311,12 +311,14 @@ async def query_mounts( *, body: MountQueryRequest, session_id: Optional[str] = Query(default=None), + agent_id: Optional[str] = Query(default=None), include_archived: bool = Query(default=False), ) -> MountsResponse: await self._check(request, Permission.VIEW_MOUNTS) mount_query = merge_mount_query( session_id=session_id, + agent_id=agent_id, include_archived=include_archived, body_query=body.mount, ) diff --git a/api/oss/src/apis/fastapi/mounts/utils.py b/api/oss/src/apis/fastapi/mounts/utils.py index 811c3b8221..101249b3be 100644 --- a/api/oss/src/apis/fastapi/mounts/utils.py +++ b/api/oss/src/apis/fastapi/mounts/utils.py @@ -168,6 +168,7 @@ async def sign_mount_credentials( def merge_mount_query( *, session_id: Optional[str] = None, + agent_id: Optional[str] = None, include_archived: bool = False, body_query: Optional[MountQuery] = None, ) -> MountQuery: @@ -177,6 +178,9 @@ def merge_mount_query( if session_id is not None: base.session_id = session_id + if agent_id is not None: + base.agent_id = agent_id + if include_archived: base.include_archived = include_archived diff --git a/api/oss/src/apis/fastapi/otlp/extractors/adapters/logfire_adapter.py b/api/oss/src/apis/fastapi/otlp/extractors/adapters/logfire_adapter.py index bb3c1df1dd..32ec482ea2 100644 --- a/api/oss/src/apis/fastapi/otlp/extractors/adapters/logfire_adapter.py +++ b/api/oss/src/apis/fastapi/otlp/extractors/adapters/logfire_adapter.py @@ -182,7 +182,8 @@ def _split_agent_messages( ("tool_response", "ag.meta.tool_response"), # System instructions ("gen_ai.system_instructions", "ag.meta.system_instructions"), - # Agent description + # Agent identity/description + ("gen_ai.agent.id", "ag.agent.id"), ("gen_ai.agent.description", "ag.meta.agent.description"), # Provider name ("gen_ai.provider.name", "ag.meta.provider.name"), diff --git a/api/oss/src/apis/fastapi/otlp/extractors/canonical_attributes.py b/api/oss/src/apis/fastapi/otlp/extractors/canonical_attributes.py index 2e71678815..aa5d3f07c8 100644 --- a/api/oss/src/apis/fastapi/otlp/extractors/canonical_attributes.py +++ b/api/oss/src/apis/fastapi/otlp/extractors/canonical_attributes.py @@ -24,6 +24,7 @@ class SpanFeatures(BaseModel): tags: Dict[str, Any] = Field(default_factory=dict) session: Dict[str, Any] = Field(default_factory=dict) user: Dict[str, Any] = Field(default_factory=dict) + agent: Dict[str, Any] = Field(default_factory=dict) class EventData(BaseModel): diff --git a/api/oss/src/apis/fastapi/otlp/extractors/span_data_builders.py b/api/oss/src/apis/fastapi/otlp/extractors/span_data_builders.py index 15260ad1b8..1eaf2d4409 100644 --- a/api/oss/src/apis/fastapi/otlp/extractors/span_data_builders.py +++ b/api/oss/src/apis/fastapi/otlp/extractors/span_data_builders.py @@ -149,6 +149,7 @@ def build( attributes.update(**{f"ag.session.{k}": v for k, v in features.session.items()}) attributes.update(**{f"ag.user.{k}": v for k, v in features.user.items()}) + attributes.update(**{f"ag.agent.{k}": v for k, v in features.agent.items()}) attributes.update(**{f"ag.references.{k}": v for k, v in features.refs.items()}) diff --git a/api/oss/src/apis/fastapi/otlp/utils/serialization.py b/api/oss/src/apis/fastapi/otlp/utils/serialization.py index 7eb5ccac63..8709f7ba68 100644 --- a/api/oss/src/apis/fastapi/otlp/utils/serialization.py +++ b/api/oss/src/apis/fastapi/otlp/utils/serialization.py @@ -15,6 +15,7 @@ "ag.exception.": "exception", "ag.session.": "session", "ag.user.": "user", + "ag.agent.": "agent", } diff --git a/api/oss/src/apis/fastapi/sessions/models.py b/api/oss/src/apis/fastapi/sessions/models.py index dd701fd4af..2a3c0fcd68 100644 --- a/api/oss/src/apis/fastapi/sessions/models.py +++ b/api/oss/src/apis/fastapi/sessions/models.py @@ -1,14 +1,12 @@ from datetime import datetime -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Literal, Optional from uuid import UUID -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator from oss.src.core.sessions.streams.dtos import ( - CommandMode, SessionStream, ) -from oss.src.core.sessions.states.dtos import SessionState, SessionStateData from oss.src.core.sessions.records.dtos import SessionRecord from oss.src.core.sessions.interactions.dtos import ( SessionInteraction, @@ -19,92 +17,55 @@ SessionInteractionStatus, ) from oss.src.core.sessions.mounts.dtos import SessionMount, SessionMountQuery -from oss.src.core.shared.dtos import Windowing +from oss.src.core.sessions.turns.dtos import HarnessKind, SessionTurn, SessionTurnQuery +from oss.src.core.shared.dtos import OTelSpanId, Reference, Windowing # --------------------------------------------------------------------------- -# Streams request/response models +# Root session-level request/response models (query/delete/archive/unarchive) # --------------------------------------------------------------------------- -class SessionStreamCommandRequestModel(BaseModel): - session_id: str - prompt: Optional[str] = None - force: bool = False - detached: bool = False +class SessionQueryRequest(BaseModel): + references: Optional[List[Reference]] = None + windowing: Optional[Windowing] = None -class SessionHeartbeatRequestModel(BaseModel): - # project scope comes from the caller's credential, never the body - session_id: str - replica_id: str = Field(min_length=1) - turn_id: Optional[str] = None - is_running: bool = True +class SessionsResponse(BaseModel): + count: int = 0 + sessions: List[SessionStream] = Field(default_factory=list) -class SessionDetachRequestModel(BaseModel): - session_id: str - watcher_id: str +class SessionResponse(BaseModel): + count: int = 0 + session: Optional[SessionStream] = None -class SessionStreamQueryRequestModel(BaseModel): - session_id: Optional[str] = None - is_alive: Optional[bool] = None - is_running: Optional[bool] = None +# --------------------------------------------------------------------------- +# Streams request/response models +# --------------------------------------------------------------------------- -class SessionStreamCommandResponseModel(BaseModel): - mode: CommandMode +class SessionDetachRequest(BaseModel): session_id: str - turn_id: Optional[str] = None - watcher_id: Optional[str] = None - detached: bool = False + watcher_id: str -class SessionStreamResponseModel(BaseModel): - stream: Optional[SessionStream] = None +class SessionStreamQueryRequest(BaseModel): + session_id: Optional[str] = None + is_alive: Optional[bool] = None + is_running: Optional[bool] = None -class SessionHeartbeatResponseModel(BaseModel): - # the replica owning the session after the heartbeat's claim; the runner compares it - # to its own replica_id to refuse serving a session it doesn't own. +class SessionStreamResponse(BaseModel): stream: Optional[SessionStream] = None - replica_id: str -class SessionStreamsResponseModel(BaseModel): +class SessionStreamsResponse(BaseModel): count: int streams: List[SessionStream] -# --------------------------------------------------------------------------- -# States request/response models -# --------------------------------------------------------------------------- - - -class SessionStateResponse(BaseModel): - count: int = Field(default=0) - session_state: Optional[SessionState] = Field(default=None) - - -class SessionStateUpsertRequest(BaseModel): - data: Optional[SessionStateData] = Field( - default=None, - description="Full replacement of the continuity state (resume ids + staleness guard).", - ) - sandbox_id: Optional[str] = Field( - default=None, - description="Remote sandbox id to record alongside the continuity state.", - ) - sandbox_turn_index: Optional[int] = Field( - default=None, - description=( - "the writer's conversation turn index; the pointer write is applied only " - "when it is >= the row's data.latest_turn_index." - ), - ) - - # --------------------------------------------------------------------------- # Records request/response models # --------------------------------------------------------------------------- @@ -140,16 +101,34 @@ class SessionInteractionCreateRequest(BaseModel): meta: Optional[Dict[str, Any]] = None +class SessionInteractionResolution(BaseModel): + model_config = ConfigDict(extra="forbid") + + verdict: Literal["approved", "denied"] + tool_call_id: str + + class SessionInteractionTransitionRequest(BaseModel): # No project_id: scope comes from the caller's credential (request.state). session_id: str token: str status: SessionInteractionStatus + resolution: Optional[SessionInteractionResolution] = None + + @model_validator(mode="after") + def validate_resolution_status(self) -> "SessionInteractionTransitionRequest": + # Resolution is answer data, so it is valid only on the resolved lifecycle edge. + if ( + self.resolution is not None + and self.status != SessionInteractionStatus.resolved + ): + raise ValueError("resolution is only valid when status is resolved") + return self class SessionInteractionCancelStaleRequest(BaseModel): - # Cancels prior turns' pending gates, sparing this turn's own (`turn_id`) and any gates - # the current turn answers in-band (`tokens` — a resume must resolve them, not cancel). + # Cancels prior turns' pending gates, sparing this turn's own (`turn_id`) and every prior + # gate still owned by a live partial resume (`tokens`, including carried gates). session_id: str turn_id: str tokens: Optional[List[str]] = None @@ -194,6 +173,49 @@ class SessionMountsResponse(BaseModel): mounts: List[SessionMount] = Field(default_factory=list) +# --------------------------------------------------------------------------- +# Turns request/response models +# --------------------------------------------------------------------------- + + +class SessionTurnAppendRequest(BaseModel): + # No project_id: scope comes from the caller's credential (request.state). + session_id: str + turn_id: Optional[UUID] = None + stream_id: UUID + turn_index: int + harness_kind: HarnessKind + agent_session_id: Optional[str] = None + sandbox_id: Optional[str] = None + references: Optional[List[Reference]] = None + trace_id: Optional[UUID] = None + span_id: Optional[OTelSpanId] = None + start_time: Optional[datetime] = None + end_time: Optional[datetime] = None + + +class SessionTurnCompleteRequest(BaseModel): + session_id: str + turn_index: int + agent_session_id: Optional[str] = None + end_time: datetime + + +class SessionTurnQueryRequest(BaseModel): + query: Optional[SessionTurnQuery] = None + windowing: Optional[Windowing] = None + + +class SessionTurnResponse(BaseModel): + count: int = 0 + turn: Optional[SessionTurn] = None + + +class SessionTurnsResponse(BaseModel): + count: int = 0 + turns: List[SessionTurn] = Field(default_factory=list) + + # --------------------------------------------------------------------------- # Admin record ingest model # --------------------------------------------------------------------------- @@ -209,3 +231,7 @@ class SessionRecordIngestRequest(BaseModel): record_type: Optional[str] = None record_source: Optional[str] = None attributes: Optional[Dict[str, Any]] = None + # The turn this record belongs to; span_id bridges to observability when available. + # Both forward-fill only (tracing-DB rule) — absent on producers that predate this. + turn_id: Optional[str] = None + span_id: Optional[OTelSpanId] = None diff --git a/api/oss/src/apis/fastapi/sessions/router.py b/api/oss/src/apis/fastapi/sessions/router.py index 516769f8dd..77d0f539f9 100644 --- a/api/oss/src/apis/fastapi/sessions/router.py +++ b/api/oss/src/apis/fastapi/sessions/router.py @@ -1,10 +1,19 @@ """Unified sessions API router. -Composes four sub-domain routers: +Composes the sub-domain routers: - SessionStreamsRouter — /sessions/streams/* - - SessionStatesRouter — /sessions/states/ - RecordsRouter — /sessions/records/* - InteractionsRouter — /sessions/interactions/* + - SessionTurnsRouter — /sessions/turns/* + - SessionsRootRouter — /sessions/query, /sessions/ (DELETE), + /sessions/archive, /sessions/unarchive + +peek (S12/E1) is NOT a verb and NOT a server-side aggregate. It is the front-end +composing the individual reads already exposed here: + 1. `POST /sessions/query` (this router) -> a list of session_ids. + 2. Per session_id: `GET /sessions/streams/?session_id=` (fetch the stream), + `POST /sessions/turns/query` (turns), `POST /sessions/records/query` + (records). No overlay/aggregate endpoint exists or is planned. """ import re @@ -25,7 +34,10 @@ # Core domain imports — new paths from oss.src.core.sessions.streams.dtos import ( SessionHeartbeatRequest, + SessionHeartbeatResult, SessionStreamCommandRequest, + SessionStreamCommandResponse, + SessionStreamHeaderEdit, SessionStreamQuery, SessionStreamQueryFlags, ) @@ -37,13 +49,13 @@ SessionStreamNotFound, ) from oss.src.core.sessions.streams.service import SessionStreamsService -from oss.src.core.sessions.states.service import SessionStatesService -from oss.src.core.sessions.states.dtos import SessionStateUpsert from oss.src.core.sessions.records.service import RecordsService from oss.src.core.sessions.records.dtos import SessionRecordEvent from oss.src.core.sessions.records.streaming import publish_record from oss.src.core.sessions.interactions.dtos import ( SessionInteractionCreate, + SessionInteractionKind, + SessionInteractionQuery, SessionInteractionStatus, SessionInteractionTransition, ) @@ -51,6 +63,11 @@ from oss.src.core.sessions.interactions.types import InteractionNotFound from oss.src.core.sessions.mounts.service import SessionMountsService from oss.src.core.sessions.mounts.dtos import SessionMountQuery +from oss.src.core.sessions.turns.dtos import SessionTurnComplete, SessionTurnCreate +from oss.src.core.sessions.turns.service import SessionTurnsService +from oss.src.core.sessions.turns.types import SessionTurnNotFound +from oss.src.core.sessions.dtos import SessionQuery +from oss.src.core.sessions.service import SessionsService from oss.src.core.mounts.service import MountsService from oss.src.apis.fastapi.mounts.router import handle_mount_exceptions from oss.src.apis.fastapi.mounts.utils import ( @@ -70,17 +87,10 @@ from oss.src.apis.fastapi.sessions.models import ( # streams - SessionDetachRequestModel, - SessionHeartbeatRequestModel, - SessionHeartbeatResponseModel, - SessionStreamCommandRequestModel, - SessionStreamCommandResponseModel, - SessionStreamQueryRequestModel, - SessionStreamResponseModel, - SessionStreamsResponseModel, - # states - SessionStateResponse, - SessionStateUpsertRequest, + SessionDetachRequest, + SessionStreamQueryRequest, + SessionStreamResponse, + SessionStreamsResponse, # records SessionRecordIngestRequest, SessionRecordQueryRequest, @@ -98,6 +108,16 @@ SessionMountQueryRequest, SessionMountResponse, # noqa: F401 (exported for OpenAPI/single-mount future use) SessionMountsResponse, + # turns + SessionTurnAppendRequest, + SessionTurnCompleteRequest, + SessionTurnQueryRequest, + SessionTurnResponse, + SessionTurnsResponse, + # root session-level ops + SessionQueryRequest, + SessionResponse, + SessionsResponse, ) log = get_module_logger(__name__) @@ -217,13 +237,21 @@ def __init__( tags=["Sessions"], ) + self.router.add_api_route( + "/sessions/streams/header", + self.set_session_stream_header, + methods=["PUT", "POST"], + operation_id="set_session_stream_header", + tags=["Sessions"], + ) + @intercept_exceptions() @_handle_session_exceptions() async def set_session_stream( self, request: Request, - payload: SessionStreamCommandRequestModel, - ) -> SessionStreamCommandResponseModel: + payload: SessionStreamCommandRequest, + ) -> SessionStreamCommandResponse: project_id = request.state.project_id user_id = request.state.user_id @@ -237,22 +265,10 @@ async def set_session_stream( await self._service.check_runner_concurrency_limit(project_id=project_id) - result = await self._service.command( + return await self._service.command( project_id=project_id, user_id=user_id, - request=SessionStreamCommandRequest( - session_id=payload.session_id, - prompt=payload.prompt, - force=payload.force, - detached=payload.detached, - ), - ) - return SessionStreamCommandResponseModel( - mode=result.mode, - session_id=result.session_id, - turn_id=result.turn_id, - watcher_id=result.watcher_id, - detached=result.detached, + request=payload, ) @intercept_exceptions() @@ -261,7 +277,7 @@ async def fetch_session_stream( self, request: Request, session_id: str = Query(...), - ) -> SessionStreamResponseModel: + ) -> SessionStreamResponse: project_id = request.state.project_id user_id = request.state.user_id @@ -277,7 +293,7 @@ async def fetch_session_stream( project_id=UUID(str(project_id)), session_id=session_id, ) - return SessionStreamResponseModel(stream=stream) + return SessionStreamResponse(stream=stream) @intercept_exceptions() @_handle_session_exceptions() @@ -314,7 +330,7 @@ async def delete_session_stream( async def detach_session_stream( self, request: Request, - payload: SessionDetachRequestModel, + payload: SessionDetachRequest, ) -> dict: project_id = request.state.project_id user_id = request.state.user_id @@ -340,8 +356,8 @@ async def detach_session_stream( async def heartbeat_session_stream( self, request: Request, - payload: SessionHeartbeatRequestModel, - ) -> SessionHeartbeatResponseModel: + payload: SessionHeartbeatRequest, + ) -> SessionHeartbeatResult: project_id = request.state.project_id user_id = request.state.user_id @@ -353,18 +369,9 @@ async def heartbeat_session_stream( if not has_permission: raise FORBIDDEN_EXCEPTION - result = await self._service.heartbeat( + return await self._service.heartbeat( project_id=project_id, - request=SessionHeartbeatRequest( - session_id=payload.session_id, - replica_id=payload.replica_id, - turn_id=payload.turn_id, - is_running=payload.is_running, - ), - ) - return SessionHeartbeatResponseModel( - stream=result.stream, - replica_id=result.replica_id, + request=payload, ) @intercept_exceptions() @@ -372,8 +379,8 @@ async def heartbeat_session_stream( async def query_session_streams( self, request: Request, - payload: SessionStreamQueryRequestModel, - ) -> SessionStreamsResponseModel: + payload: SessionStreamQueryRequest, + ) -> SessionStreamsResponse: project_id = request.state.project_id user_id = request.state.user_id @@ -395,70 +402,17 @@ async def query_session_streams( ), ), ) - return SessionStreamsResponseModel(count=len(streams), streams=streams) - - -class SessionStatesRouter: - """States sub-router — /sessions/states/?session_id=...""" - - __test__ = False - - def __init__(self, *, session_states_service: SessionStatesService): - self.session_states_service = session_states_service - self.router = APIRouter() - - self.router.add_api_route( - "/states/", - self.get_session_state, - methods=["GET"], - operation_id="get_state", - status_code=status.HTTP_200_OK, - response_model=SessionStateResponse, - response_model_exclude_none=True, - ) - self.router.add_api_route( - "/states/", - self.set_session_state, - methods=["PUT", "POST"], - operation_id="set_state", - status_code=status.HTTP_200_OK, - response_model=SessionStateResponse, - response_model_exclude_none=True, - ) - - @intercept_exceptions() - async def get_session_state( - self, - request: Request, - *, - session_id: str = Query(...), - ) -> SessionStateResponse: - _validate_session_id_http(session_id) - - if not await check_action_access( - user_uid=request.state.user_id, - project_id=request.state.project_id, - permission=Permission.VIEW_SESSIONS, - ): - raise FORBIDDEN_EXCEPTION - - session_state = await self.session_states_service.get_session_state( - project_id=UUID(request.state.project_id), - session_id=session_id, - ) - return SessionStateResponse( - count=1 if session_state else 0, - session_state=session_state, - ) + return SessionStreamsResponse(count=len(streams), streams=streams) @intercept_exceptions() - async def set_session_state( + @_handle_session_exceptions() + async def set_session_stream_header( self, request: Request, *, - session_state_upsert_request: SessionStateUpsertRequest, + header: SessionStreamHeaderEdit, session_id: str = Query(...), - ) -> SessionStateResponse: + ) -> SessionStreamResponse: _validate_session_id_http(session_id) if not await check_action_access( @@ -468,18 +422,13 @@ async def set_session_state( ): raise FORBIDDEN_EXCEPTION - session_state = await self.session_states_service.set_session_state( + stream = await self._service.set_header( project_id=UUID(request.state.project_id), user_id=UUID(request.state.user_id), session_id=session_id, - upsert=SessionStateUpsert( - **session_state_upsert_request.model_dump(exclude_unset=True) - ), - ) - return SessionStateResponse( - count=1 if session_state else 0, - session_state=session_state, + header=header, ) + return SessionStreamResponse(stream=stream) class RecordsRouter: @@ -584,6 +533,8 @@ async def ingest_record_event( record_type=body.record_type, record_source=body.record_source, attributes=body.attributes, + turn_id=body.turn_id, + span_id=body.span_id, ), ) return {"ok": True} @@ -691,6 +642,33 @@ async def transition_interaction( ): raise FORBIDDEN_EXCEPTION + resolution = ( + body.resolution.model_dump() if body.resolution is not None else None + ) + if resolution is not None: + interactions = await self.interactions_service.query_interactions( + project_id=project_id, + query=SessionInteractionQuery(session_id=body.session_id), + ) + source = next( + ( + interaction + for interaction in interactions + if interaction.token == body.token + ), + None, + ) + if source is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Interaction not found or already terminal", + ) + if source.kind != SessionInteractionKind.user_approval: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Resolution is only valid for user approval interactions", + ) + try: interaction = await self.interactions_service.transition_interaction( transition=SessionInteractionTransition( @@ -698,6 +676,7 @@ async def transition_interaction( session_id=body.session_id, token=body.token, status=body.status, + resolution=resolution, ), ) except InteractionNotFound: @@ -1085,6 +1064,313 @@ async def download_session_mount_file( ) +class SessionTurnsRouter: + """Turns sub-router — /sessions/turns/*""" + + def __init__(self, *, turns_service: SessionTurnsService) -> None: + self.turns_service = turns_service + self.router = APIRouter() + + self.router.add_api_route( + "/", + self.append_turn, + methods=["POST"], + operation_id="append_turn", + status_code=status.HTTP_200_OK, + response_model=SessionTurnResponse, + response_model_exclude_none=True, + ) + self.router.add_api_route( + "/complete", + self.complete_turn, + methods=["POST"], + operation_id="complete_turn", + status_code=status.HTTP_200_OK, + response_model=SessionTurnResponse, + response_model_exclude_none=True, + ) + self.router.add_api_route( + "/query", + self.query_turns, + methods=["POST"], + operation_id="query_turns", + status_code=status.HTTP_200_OK, + response_model=SessionTurnsResponse, + response_model_exclude_none=True, + ) + self.router.add_api_route( + "/{turn_id}", + self.fetch_turn, + methods=["GET"], + operation_id="fetch_turn", + status_code=status.HTTP_200_OK, + response_model=SessionTurnResponse, + response_model_exclude_none=True, + ) + + @intercept_exceptions() + async def append_turn( + self, + request: Request, + body: SessionTurnAppendRequest, + ) -> SessionTurnResponse: + project_id: UUID = request.state.project_id + user_id: UUID = request.state.user_id + + if not await check_action_access( + user_uid=str(user_id), + project_id=str(project_id), + permission=Permission.RUN_SESSIONS, + ): + raise FORBIDDEN_EXCEPTION + + turn = await self.turns_service.append_turn( + project_id=project_id, + user_id=user_id, + turn=SessionTurnCreate( + session_id=body.session_id, + turn_id=body.turn_id, + stream_id=body.stream_id, + turn_index=body.turn_index, + harness_kind=body.harness_kind, + agent_session_id=body.agent_session_id, + sandbox_id=body.sandbox_id, + references=body.references, + trace_id=body.trace_id, + span_id=body.span_id, + start_time=body.start_time, + end_time=body.end_time, + ), + ) + return SessionTurnResponse(count=1, turn=turn) + + @intercept_exceptions() + async def complete_turn( + self, + request: Request, + body: SessionTurnCompleteRequest, + ) -> SessionTurnResponse: + project_id: UUID = request.state.project_id + user_id: UUID = request.state.user_id + + if not await check_action_access( + user_uid=str(user_id), + project_id=str(project_id), + permission=Permission.RUN_SESSIONS, + ): + raise FORBIDDEN_EXCEPTION + + try: + turn = await self.turns_service.complete_turn( + project_id=project_id, + turn=SessionTurnComplete( + session_id=body.session_id, + turn_index=body.turn_index, + agent_session_id=body.agent_session_id, + end_time=body.end_time, + ), + ) + except SessionTurnNotFound as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=e.message, + ) from e + return SessionTurnResponse(count=1, turn=turn) + + @intercept_exceptions() + async def query_turns( + self, + request: Request, + body: SessionTurnQueryRequest, + ) -> SessionTurnsResponse: + project_id: UUID = request.state.project_id + user_id: UUID = request.state.user_id + + if not await check_action_access( + user_uid=str(user_id), + project_id=str(project_id), + permission=Permission.VIEW_SESSIONS, + ): + raise FORBIDDEN_EXCEPTION + + turns = await self.turns_service.query_turns( + project_id=project_id, + query=body.query, + windowing=body.windowing, + ) + return SessionTurnsResponse(count=len(turns), turns=turns) + + @intercept_exceptions() + async def fetch_turn( + self, + request: Request, + turn_id: UUID, + ) -> SessionTurnResponse: + project_id: UUID = request.state.project_id + user_id: UUID = request.state.user_id + + if not await check_action_access( + user_uid=str(user_id), + project_id=str(project_id), + permission=Permission.VIEW_SESSIONS, + ): + raise FORBIDDEN_EXCEPTION + + turn = await self.turns_service.fetch_turn( + project_id=project_id, + turn_id=turn_id, + ) + return SessionTurnResponse(count=1 if turn else 0, turn=turn) + + +class SessionsRootRouter: + """Root session-level operations — /sessions/query, /sessions/ (DELETE), + /sessions/archive, /sessions/unarchive. + + Orchestrates across facets via `SessionsService`, anchored on `session_id` + (never `stream_id`). RBAC: VIEW_SESSIONS for query, EDIT_SESSIONS for the + three mutations. + """ + + def __init__(self, *, sessions_service: SessionsService) -> None: + self.sessions_service = sessions_service + self.router = APIRouter() + + self.router.add_api_route( + "/sessions/query", + self.query_sessions, + methods=["POST"], + operation_id="query_sessions", + status_code=status.HTTP_200_OK, + response_model=SessionsResponse, + response_model_exclude_none=True, + tags=["Sessions"], + ) + self.router.add_api_route( + "/sessions/", + self.delete_session, + methods=["DELETE"], + operation_id="delete_session", + status_code=status.HTTP_200_OK, + tags=["Sessions"], + ) + self.router.add_api_route( + "/sessions/archive", + self.archive_session, + methods=["POST"], + operation_id="archive_session", + status_code=status.HTTP_200_OK, + response_model=SessionResponse, + response_model_exclude_none=True, + tags=["Sessions"], + ) + self.router.add_api_route( + "/sessions/unarchive", + self.unarchive_session, + methods=["POST"], + operation_id="unarchive_session", + status_code=status.HTTP_200_OK, + response_model=SessionResponse, + response_model_exclude_none=True, + tags=["Sessions"], + ) + + @intercept_exceptions() + async def query_sessions( + self, + request: Request, + body: SessionQueryRequest, + ) -> SessionsResponse: + project_id = request.state.project_id + user_id = request.state.user_id + + if not await check_action_access( + user_uid=str(user_id), + project_id=str(project_id), + permission=Permission.VIEW_SESSIONS, + ): + raise FORBIDDEN_EXCEPTION + + sessions = await self.sessions_service.query_sessions( + project_id=UUID(str(project_id)), + query=SessionQuery(references=body.references), + windowing=body.windowing, + ) + return SessionsResponse(count=len(sessions), sessions=sessions) + + @intercept_exceptions() + async def delete_session( + self, + request: Request, + session_id: str = Query(...), + ) -> dict: + _validate_session_id_http(session_id) + project_id = request.state.project_id + user_id = request.state.user_id + + if not await check_action_access( + user_uid=str(user_id), + project_id=str(project_id), + permission=Permission.EDIT_SESSIONS, + ): + raise FORBIDDEN_EXCEPTION + + await self.sessions_service.delete_session( + project_id=UUID(str(project_id)), + user_id=UUID(str(user_id)), + session_id=session_id, + ) + return {"ok": True} + + @intercept_exceptions() + async def archive_session( + self, + request: Request, + session_id: str = Query(...), + ) -> SessionResponse: + _validate_session_id_http(session_id) + project_id = request.state.project_id + user_id = request.state.user_id + + if not await check_action_access( + user_uid=str(user_id), + project_id=str(project_id), + permission=Permission.EDIT_SESSIONS, + ): + raise FORBIDDEN_EXCEPTION + + session = await self.sessions_service.archive_session( + project_id=UUID(str(project_id)), + user_id=UUID(str(user_id)), + session_id=session_id, + ) + return SessionResponse(count=1 if session else 0, session=session) + + @intercept_exceptions() + async def unarchive_session( + self, + request: Request, + session_id: str = Query(...), + ) -> SessionResponse: + _validate_session_id_http(session_id) + project_id = request.state.project_id + user_id = request.state.user_id + + if not await check_action_access( + user_uid=str(user_id), + project_id=str(project_id), + permission=Permission.EDIT_SESSIONS, + ): + raise FORBIDDEN_EXCEPTION + + session = await self.sessions_service.unarchive_session( + project_id=UUID(str(project_id)), + user_id=UUID(str(user_id)), + session_id=session_id, + ) + return SessionResponse(count=1 if session else 0, session=session) + + # --------------------------------------------------------------------------- # Top-level composer # --------------------------------------------------------------------------- @@ -1095,29 +1381,30 @@ class SessionsRouter: The entrypoint mounts: sessions_router.streams.router → no prefix (paths include /sessions/streams/…) - sessions_router.states.router → prefix /sessions sessions_router.records.router → prefix /sessions/records sessions_router.interactions.router → prefix /sessions/interactions sessions_router.mounts.router → prefix /sessions + sessions_router.turns.router → prefix /sessions/turns + sessions_router.root.router → no prefix (paths include /sessions/query, /sessions/, /sessions/archive, /sessions/unarchive) """ def __init__( self, *, streams_service: SessionStreamsService, - states_service: SessionStatesService, records_service: RecordsService, interactions_service: SessionInteractionsService, workflows_service: WorkflowsService, session_mounts_service: SessionMountsService, mounts_service: MountsService, + turns_service: SessionTurnsService, + sessions_service: SessionsService, respond_task: Optional[Any] = None, ) -> None: self.streams = SessionStreamsRouter( service=streams_service, interactions_service=interactions_service, ) - self.states = SessionStatesRouter(session_states_service=states_service) self.records = RecordsRouter(records_service=records_service) self.interactions = InteractionsRouter( interactions_service=interactions_service, @@ -1128,3 +1415,5 @@ def __init__( session_mounts_service=session_mounts_service, mounts_service=mounts_service, ) + self.turns = SessionTurnsRouter(turns_service=turns_service) + self.root = SessionsRootRouter(sessions_service=sessions_service) diff --git a/api/oss/src/apis/fastapi/tracing/router.py b/api/oss/src/apis/fastapi/tracing/router.py index d84aaf7b40..5ee060c5b4 100644 --- a/api/oss/src/apis/fastapi/tracing/router.py +++ b/api/oss/src/apis/fastapi/tracing/router.py @@ -178,7 +178,7 @@ def __init__( "/sessions/query", self.list_sessions, methods=["POST"], - operation_id="query_sessions", + operation_id="query_sessions_tracing", status_code=status.HTTP_200_OK, response_model=SessionIdsResponse, response_model_exclude_none=True, diff --git a/api/oss/src/core/mounts/dtos.py b/api/oss/src/core/mounts/dtos.py index 3b0d89f4f7..a7deac924c 100644 --- a/api/oss/src/core/mounts/dtos.py +++ b/api/oss/src/core/mounts/dtos.py @@ -25,6 +25,7 @@ class MountFlags(BaseModel): class Mount(Identifier, Slug, Header, Lifecycle): project_id: UUID session_id: Optional[str] = None + agent_id: Optional[str] = None # data: MountData = Field(default_factory=MountData) # @@ -35,6 +36,7 @@ class Mount(Identifier, Slug, Header, Lifecycle): class MountCreate(Slug, Header): session_id: Optional[str] = None + agent_id: Optional[str] = None # flags: MountFlags = Field(default_factory=MountFlags) tags: Optional[Dict[str, Any]] = None @@ -49,6 +51,7 @@ class MountEdit(Identifier, Header): class MountQuery(BaseModel): session_id: Optional[str] = None + agent_id: Optional[str] = None include_archived: bool = False diff --git a/api/oss/src/core/mounts/interfaces.py b/api/oss/src/core/mounts/interfaces.py index fa26dc530b..45ea77b8b3 100644 --- a/api/oss/src/core/mounts/interfaces.py +++ b/api/oss/src/core/mounts/interfaces.py @@ -85,3 +85,11 @@ async def query_mounts( # windowing: Optional[Windowing] = None, ) -> List[Mount]: ... + + @abstractmethod + async def delete_by_session_id( + self, + *, + project_id: UUID, + session_id: str, + ) -> List[Mount]: ... diff --git a/api/oss/src/core/mounts/service.py b/api/oss/src/core/mounts/service.py index 02dd911445..cde0df2932 100644 --- a/api/oss/src/core/mounts/service.py +++ b/api/oss/src/core/mounts/service.py @@ -86,17 +86,25 @@ def mint_session_slug(*, session_id: str, name: str) -> str: return f"{_RESERVED_SLUG_PREFIX}session__{uuid5(_MOUNTS_NAMESPACE, session_id)}__{slugify_mount_name(name)}" -def mint_agent_slug(*, artifact_id: str, name: str) -> str: - """Mint the deterministic reserved slug for an artifact mount. +def mint_agent_id(*, artifact_id: str) -> str: + """Canonicalize an artifact id: UUID-parsed, rendered lowercase. - Artifact IDs are UUID-parsed and rendered lowercase. Sign and query must use - this same derivation byte-identically so they address the same mount. + Shared by `mint_agent_slug` (the slug's id segment) and the mount's queryable + `agent_id` column, so both derive byte-identically from the same artifact id. """ try: - canonical_artifact_id = UUID(str(artifact_id)) + return str(UUID(str(artifact_id))) except (ValueError, TypeError, AttributeError) as e: raise MountArtifactIdInvalid(str(artifact_id)) from e + +def mint_agent_slug(*, artifact_id: str, name: str) -> str: + """Mint the deterministic reserved slug for an artifact mount. + + Artifact IDs are UUID-parsed and rendered lowercase. Sign and query must use + this same derivation byte-identically so they address the same mount. + """ + canonical_artifact_id = mint_agent_id(artifact_id=artifact_id) slug_name = slugify_mount_name(name) return f"{_RESERVED_SLUG_PREFIX}agent__{canonical_artifact_id}__{slug_name}" @@ -466,9 +474,11 @@ async def get_or_create_agent_mount( ) slug_name = slugify_mount_name(name) + canonical_artifact_id = mint_agent_id(artifact_id=artifact_id) mount_create = MountCreate( slug=mint_agent_slug(artifact_id=artifact_id, name=name), name=slug_name, + agent_id=canonical_artifact_id, ) return await self.mounts_dao.upsert_mount( project_id=project_id, @@ -582,6 +592,78 @@ async def query_mounts( windowing=windowing, ) + async def delete_session_mounts( + self, + *, + project_id: UUID, + session_id: str, + ) -> List[Mount]: + """Hard-delete every mount bound to a session, tearing down each mount's + object-store prefix. Explicit + session-aware (S7/F1, WP5): mounts are + semi-independent (optional `session_id`, can outlive a session), so this + is a targeted fan-out, never a blind cascade.""" + mounts = await self.mounts_dao.delete_by_session_id( + project_id=project_id, + session_id=session_id, + ) + if self.mounts_store is not None and self.bucket: + for mount in mounts: + prefix = self._storage_key(project_id=project_id, mount=mount) + await self.mounts_store.delete_prefix( + bucket=self.bucket, + prefix=prefix, + ) + return mounts + + async def archive_session_mounts( + self, + *, + project_id: UUID, + user_id: UUID, + session_id: str, + ) -> List[Mount]: + """Soft-archive every mount bound to a session (reversible; folders + untouched). Session archive fan-out (S7/F2, WP5).""" + mounts = await self.mounts_dao.query_mounts( + project_id=project_id, + mount_query=MountQuery(session_id=session_id, include_archived=False), + ) + archived: List[Mount] = [] + for mount in mounts: + result = await self.mounts_dao.archive_mount( + project_id=project_id, + user_id=user_id, + mount_id=mount.id, + ) + if result is not None: + archived.append(result) + return archived + + async def unarchive_session_mounts( + self, + *, + project_id: UUID, + user_id: UUID, + session_id: str, + ) -> List[Mount]: + """Reverse of `archive_session_mounts`.""" + mounts = await self.mounts_dao.query_mounts( + project_id=project_id, + mount_query=MountQuery(session_id=session_id, include_archived=True), + ) + unarchived: List[Mount] = [] + for mount in mounts: + if mount.deleted_at is None: + continue + result = await self.mounts_dao.unarchive_mount( + project_id=project_id, + user_id=user_id, + mount_id=mount.id, + ) + if result is not None: + unarchived.append(result) + return unarchived + # --- File ops (durable store contents) --------------------------------- # async def _resolve_mount( diff --git a/api/oss/src/core/sessions/dtos.py b/api/oss/src/core/sessions/dtos.py new file mode 100644 index 0000000000..bfcdcb1bb9 --- /dev/null +++ b/api/oss/src/core/sessions/dtos.py @@ -0,0 +1,12 @@ +from typing import List, Optional + +from pydantic import BaseModel + +from oss.src.core.shared.dtos import Reference + + +class SessionQuery(BaseModel): + """Root `/sessions/query` filter: reference-scoped, joined through the turns' + references (WP1's GIN `.contains()`), not denormalized onto the stream row.""" + + references: Optional[List[Reference]] = None diff --git a/api/oss/src/core/sessions/interactions/dtos.py b/api/oss/src/core/sessions/interactions/dtos.py index 91b61d9e71..cd136a3a76 100644 --- a/api/oss/src/core/sessions/interactions/dtos.py +++ b/api/oss/src/core/sessions/interactions/dtos.py @@ -4,7 +4,7 @@ from pydantic import BaseModel -from oss.src.core.shared.dtos import Reference, Selector +from oss.src.core.shared.dtos import Identifier, Lifecycle, Reference, Selector class SessionInteractionKind(str, Enum): @@ -38,16 +38,7 @@ class SessionInteractionQueryFlags(BaseModel): delivered_webhook: Optional[bool] = None -class SessionInteraction(BaseModel): - id: Optional[UUID] = None - # - created_at: Optional[Any] = None - updated_at: Optional[Any] = None - deleted_at: Optional[Any] = None - created_by_id: Optional[UUID] = None - updated_by_id: Optional[UUID] = None - deleted_by_id: Optional[UUID] = None - # +class SessionInteraction(Identifier, Lifecycle): project_id: Optional[UUID] = None session_id: str turn_id: Optional[str] = None @@ -77,6 +68,7 @@ class SessionInteractionTransition(BaseModel): session_id: str token: str status: SessionInteractionStatus + resolution: Optional[Dict[str, Any]] = None class SessionInteractionQuery(BaseModel): diff --git a/api/oss/src/core/sessions/interactions/interfaces.py b/api/oss/src/core/sessions/interactions/interfaces.py index 17a97ffbd4..f66f6e9bb1 100644 --- a/api/oss/src/core/sessions/interactions/interfaces.py +++ b/api/oss/src/core/sessions/interactions/interfaces.py @@ -57,3 +57,11 @@ async def query_interactions( query: Optional[SessionInteractionQuery] = None, windowing: Optional[Windowing] = None, ) -> List[SessionInteraction]: ... + + @abstractmethod + async def delete_by_session_id( + self, + *, + project_id: UUID, + session_id: str, + ) -> int: ... diff --git a/api/oss/src/core/sessions/interactions/service.py b/api/oss/src/core/sessions/interactions/service.py index 3a86c049c7..16130b252d 100644 --- a/api/oss/src/core/sessions/interactions/service.py +++ b/api/oss/src/core/sessions/interactions/service.py @@ -89,3 +89,15 @@ async def query_interactions( query=query, windowing=windowing, ) + + async def delete_by_session_id( + self, + *, + project_id: UUID, + session_id: str, + ) -> int: + """Hard delete every interaction for a session (S7 delete fan-out, WP5).""" + return await self.interactions_dao.delete_by_session_id( + project_id=project_id, + session_id=session_id, + ) diff --git a/api/oss/src/core/sessions/records/dtos.py b/api/oss/src/core/sessions/records/dtos.py index b725b03185..44dd01092f 100644 --- a/api/oss/src/core/sessions/records/dtos.py +++ b/api/oss/src/core/sessions/records/dtos.py @@ -4,6 +4,8 @@ from pydantic import BaseModel +from oss.src.core.shared.dtos import Lifecycle, OTelSpanId + class SessionRecordEvent(BaseModel): project_id: UUID @@ -16,8 +18,12 @@ class SessionRecordEvent(BaseModel): record_source: Optional[str] = None attributes: Optional[Dict[str, Any]] = None + # Forward-fill only (tracing-DB rule): populated on new records, null on old ones. + turn_id: Optional[str] = None + span_id: Optional[OTelSpanId] = None + -class SessionRecord(BaseModel): +class SessionRecord(Lifecycle): record_id: UUID session_id: str @@ -29,7 +35,8 @@ class SessionRecord(BaseModel): record_source: Optional[str] = None attributes: Optional[Dict[str, Any]] = None - created_at: Optional[datetime] = None + turn_id: Optional[str] = None + span_id: Optional[OTelSpanId] = None class SessionRecordQuery(BaseModel): diff --git a/api/oss/src/core/sessions/service.py b/api/oss/src/core/sessions/service.py new file mode 100644 index 0000000000..978409f0f4 --- /dev/null +++ b/api/oss/src/core/sessions/service.py @@ -0,0 +1,146 @@ +"""Root session-level operations: query/list, delete, archive, unarchive. + +Orchestrates across the session facets (streams, turns, interactions, mounts), +anchored on `session_id` — the universal handle. Fan-out NEVER routes through +`stream_id`. Records (tracing DB) are untouched here; tracing retention owns +them. + +peek is NOT a verb and NOT built here: the individual reads (this service's +`query_sessions`, the streams/turns/records fetch-and-query endpoints) are the +whole surface. The front-end composes them — see `apis/fastapi/sessions/router.py` +module docstring for the read-walk. +""" + +from typing import List, Optional +from uuid import UUID + +from oss.src.core.shared.dtos import Reference, Windowing +from oss.src.core.sessions.dtos import SessionQuery +from oss.src.core.sessions.streams.dtos import SessionStream, SessionStreamQuery +from oss.src.core.sessions.streams.service import SessionStreamsService +from oss.src.core.sessions.turns.dtos import SessionTurnQuery +from oss.src.core.sessions.turns.service import SessionTurnsService +from oss.src.core.sessions.interactions.service import SessionInteractionsService +from oss.src.core.mounts.service import MountsService + + +class SessionsService: + def __init__( + self, + *, + streams_service: SessionStreamsService, + turns_service: SessionTurnsService, + interactions_service: SessionInteractionsService, + mounts_service: MountsService, + ) -> None: + self.streams_service = streams_service + self.turns_service = turns_service + self.interactions_service = interactions_service + self.mounts_service = mounts_service + + async def query_sessions( + self, + *, + project_id: UUID, + # + query: Optional[SessionQuery] = None, + windowing: Optional[Windowing] = None, + ) -> List[SessionStream]: + """List/filter sessions, newest -> oldest, windowed. + + Reads the merged stream rows; when `references` is set, first joins the + turns' references (WP1's GIN `.contains()`) to resolve the matching + `session_id`s, then filters the stream query to that set. No + denormalization onto the stream row (B3) — revisit only if the join + proves hot. + """ + session_ids: Optional[List[str]] = None + + references: Optional[List[Reference]] = query.references if query else None + if references: + matching_turns = await self.turns_service.query_turns( + project_id=project_id, + query=SessionTurnQuery(references=references), + ) + session_ids = sorted({turn.session_id for turn in matching_turns}) + if not session_ids: + return [] + + return await self.streams_service.query_streams( + project_id=project_id, + filter=SessionStreamQuery(), + windowing=windowing, + session_ids=session_ids, + ) + + async def delete_session( + self, + *, + project_id: UUID, + user_id: UUID, + session_id: str, + ) -> None: + """Hard delete, `session_id`-scoped fan-out (F1). No DB cascade: + + - session_turns: hard delete (WP1's `delete_by_session_id`). + - session_interactions: hard delete (new — soft-only before this). + - the merged stream row: hard delete (new — `kill` only soft-deletes). + - session-bound mounts: delete the rows + their object-store prefixes + (explicit, session-aware — mounts are semi-independent). + - records: UNTOUCHED — cross-DB, tracing retention owns them. + """ + await self.turns_service.delete_by_session_id( + project_id=project_id, + session_id=session_id, + ) + await self.interactions_service.delete_by_session_id( + project_id=project_id, + session_id=session_id, + ) + await self.mounts_service.delete_session_mounts( + project_id=project_id, + session_id=session_id, + ) + await self.streams_service.hard_delete( + project_id=project_id, + session_id=session_id, + ) + + async def archive_session( + self, + *, + project_id: UUID, + user_id: UUID, + session_id: str, + ) -> Optional[SessionStream]: + """Soft (`deleted_at`) fan-out (F2): archives the stream row and soft- + archives the bound mounts too (reversible); folders untouched.""" + await self.mounts_service.archive_session_mounts( + project_id=project_id, + user_id=user_id, + session_id=session_id, + ) + return await self.streams_service.archive( + project_id=project_id, + session_id=session_id, + ) + + async def unarchive_session( + self, + *, + project_id: UUID, + user_id: UUID, + session_id: str, + ) -> Optional[SessionStream]: + """Reverse of `archive_session`: clears `deleted_at` on the stream row + and un-archives the bound mounts.""" + await self.mounts_service.unarchive_session_mounts( + project_id=project_id, + user_id=user_id, + session_id=session_id, + ) + return await self.streams_service.unarchive( + project_id=project_id, + user_id=user_id, + session_id=session_id, + ) diff --git a/api/oss/src/core/sessions/states/dtos.py b/api/oss/src/core/sessions/states/dtos.py deleted file mode 100644 index adb674dbd3..0000000000 --- a/api/oss/src/core/sessions/states/dtos.py +++ /dev/null @@ -1,94 +0,0 @@ -from typing import Any, Dict, Optional -from uuid import UUID - -from pydantic import BaseModel, Field - -from oss.src.core.shared.dtos import Lifecycle - - -class SessionStateFlags(BaseModel): - pass - - -class HarnessSessionRecord(BaseModel): - """Per-harness resume state. Value shape of `data.harness_sessions[]`.""" - - agent_session_id: Optional[str] = Field( - default=None, - description="This harness's own agentSessionId, fed to session/load on resume.", - ) - turn_index: Optional[int] = Field( - default=None, - description=( - "Conversation turn number this harness last ran at. Load-eligible only " - "when equal to the conversation's latest_turn_index; otherwise this " - "harness's session file is stale (another harness ran since)." - ), - ) - - -class SessionStateData(BaseModel): - """Typed shape of the `data` column: the session's durable continuity state. - - Stored in the existing `data` JSON column (no dedicated columns) — every field is - read and compared in the runner, never queried server-side, so a typed DTO gives the - contract without a schema change. - """ - - latest_agent_session_id: Optional[str] = Field( - default=None, - description=( - "The latest-run harness's agentSessionId; a fast-path mirror of " - "harness_sessions[].agent_session_id." - ), - ) - latest_turn_index: Optional[int] = Field( - default=None, - description="Conversation-level turn counter compared against each harness's " - "turn_index.", - ) - harness_sessions: Optional[Dict[str, HarnessSessionRecord]] = Field( - default=None, - description=( - "Per-harness resume state, keyed by harness id (e.g. 'claude', 'pi'). " - "Durable mirror of the staleness guard." - ), - ) - - -class SessionState(Lifecycle): - id: Optional[UUID] = Field(default=None, description="Own uuid7 pk (state_id).") - project_id: Optional[UUID] = Field(default=None) - session_id: str = Field(description="Bare session correlator (not an FK).") - data: Optional[SessionStateData] = Field( - default=None, - description="Durable continuity state (resume ids + staleness guard).", - ) - sandbox_id: Optional[str] = Field( - default=None, - description="Remote sandbox id — the single source of truth resume pointer.", - ) - flags: SessionStateFlags = Field(default_factory=SessionStateFlags) - tags: Optional[Dict[str, Any]] = Field(default=None) - meta: Optional[Dict[str, Any]] = Field(default=None) - - -class SessionStateUpsert(BaseModel): - data: Optional[SessionStateData] = Field( - default=None, - description=( - "Full replacement of the continuity state. Callers read-modify-write: " - "GET the current row, patch the one harness's entry, PUT the whole data back." - ), - ) - sandbox_id: Optional[str] = Field( - default=None, - description="Remote sandbox id to record alongside the continuity state.", - ) - sandbox_turn_index: Optional[int] = Field( - default=None, - description=( - "the writer's conversation turn index; the pointer write is applied only " - "when it is >= the row's data.latest_turn_index." - ), - ) diff --git a/api/oss/src/core/sessions/states/interfaces.py b/api/oss/src/core/sessions/states/interfaces.py deleted file mode 100644 index 29efa50b51..0000000000 --- a/api/oss/src/core/sessions/states/interfaces.py +++ /dev/null @@ -1,25 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Optional -from uuid import UUID - -from oss.src.core.sessions.states.dtos import SessionState, SessionStateUpsert - - -class SessionStatesDAOInterface(ABC): - @abstractmethod - async def get_session_state( - self, - *, - project_id: UUID, - session_id: str, - ) -> Optional[SessionState]: ... - - @abstractmethod - async def set_session_state( - self, - *, - project_id: UUID, - user_id: UUID, - session_id: str, - upsert: SessionStateUpsert, - ) -> Optional[SessionState]: ... diff --git a/api/oss/src/core/sessions/states/service.py b/api/oss/src/core/sessions/states/service.py deleted file mode 100644 index e3de29ec3f..0000000000 --- a/api/oss/src/core/sessions/states/service.py +++ /dev/null @@ -1,36 +0,0 @@ -from typing import Optional -from uuid import UUID - -from oss.src.core.sessions.states.dtos import SessionState, SessionStateUpsert -from oss.src.core.sessions.states.interfaces import SessionStatesDAOInterface - - -class SessionStatesService: - def __init__(self, *, session_states_dao: SessionStatesDAOInterface): - self.session_states_dao = session_states_dao - - async def get_session_state( - self, - *, - project_id: UUID, - session_id: str, - ) -> Optional[SessionState]: - return await self.session_states_dao.get_session_state( - project_id=project_id, - session_id=session_id, - ) - - async def set_session_state( - self, - *, - project_id: UUID, - user_id: UUID, - session_id: str, - upsert: SessionStateUpsert, - ) -> Optional[SessionState]: - return await self.session_states_dao.set_session_state( - project_id=project_id, - user_id=user_id, - session_id=session_id, - upsert=upsert, - ) diff --git a/api/oss/src/core/sessions/streams/dtos.py b/api/oss/src/core/sessions/streams/dtos.py index 06854bbdf6..2439458e4d 100644 --- a/api/oss/src/core/sessions/streams/dtos.py +++ b/api/oss/src/core/sessions/streams/dtos.py @@ -1,9 +1,12 @@ -from datetime import datetime from enum import Enum from typing import Any, Dict, Optional from uuid import UUID -from pydantic import BaseModel +from pydantic import BaseModel, Field + +from agenta.sdk.models.workflows import WorkflowServiceRequestData + +from oss.src.core.shared.dtos import Header, Identifier, Lifecycle class SessionStreamFlags(BaseModel): @@ -24,16 +27,7 @@ class SessionStreamQueryFlags(BaseModel): is_attached: Optional[bool] = None -class SessionStream(BaseModel): - id: UUID - # - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None - deleted_at: Optional[datetime] = None - created_by_id: Optional[UUID] = None - updated_by_id: Optional[UUID] = None - deleted_by_id: Optional[UUID] = None - # +class SessionStream(Identifier, Header, Lifecycle): project_id: UUID session_id: str flags: SessionStreamFlags = SessionStreamFlags() @@ -42,7 +36,7 @@ class SessionStream(BaseModel): turn_id: Optional[str] = None -class SessionStreamCreate(BaseModel): +class SessionStreamCreate(Header): session_id: str flags: Optional[SessionStreamFlags] = None tags: Optional[Dict[str, Any]] = None @@ -50,35 +44,46 @@ class SessionStreamCreate(BaseModel): turn_id: Optional[str] = None -class SessionStreamEdit(BaseModel): +class SessionStreamEdit(Header): flags: Optional[SessionStreamFlags] = None tags: Optional[Dict[str, Any]] = None meta: Optional[Dict[str, Any]] = None turn_id: Optional[str] = None +class SessionStreamHeaderEdit(Header): + """The rename edit: a full-PUT of the header fields only. + + Distinct from SessionStreamEdit (used by the flag-mirror/heartbeat paths) so the + liveness-only writes can never carry name/description, and vice versa. + """ + + class SessionStreamQuery(BaseModel): session_id: Optional[str] = None flags: Optional[SessionStreamQueryFlags] = None class CommandMode(str, Enum): - """Derived from the prompt × force matrix.""" + """Derived from the inputs/data × force matrix.""" - send = "send" # prompt + no force → 409 if alive - steer = "steer" # prompt + force → cancel holder, run new - cancel = "cancel" # no prompt + no force → cancel holder - attach = "attach" # no prompt + force → steal attached, watch + send = "send" # inputs + no force → 409 if alive + steer = "steer" # inputs + force → cancel holder, run new + cancel = "cancel" # no inputs + no force → cancel holder + attach = "attach" # no inputs + force → steal attached, watch class SessionStreamCommandRequest(BaseModel): """The set_session_stream edit: a state mutation over the lock/row nest. Runs nothing itself — the runner (execution plane) is the only thing that runs. + `data` mirrors the workflow-invoke shape (`WorkflowServiceRequestData`, keyed on + `.inputs`) so the discriminator aligns with `WorkflowInvokeRequest.data.inputs` + rather than a bespoke `prompt` string. """ session_id: str - prompt: Optional[str] = None + data: Optional[WorkflowServiceRequestData] = None force: bool = False detached: bool = False # fire-and-forget mode @@ -93,7 +98,7 @@ class SessionStreamCommandResponse(BaseModel): class SessionHeartbeatRequest(BaseModel): session_id: str - replica_id: str # the runner CONTAINER (affinity / owner key) + replica_id: str = Field(min_length=1) # the runner CONTAINER (affinity / owner key) turn_id: Optional[str] = None # the current TURN (proves alive-lock ownership) is_running: bool = True @@ -113,7 +118,14 @@ class SessionHeartbeatResult(BaseModel): `stream` is None when a losing replica heartbeats a session that has no row yet: it may not create or stamp one, since that row belongs to the owner. + + `is_current_turn` (W7.4) is False when this turn_id's alive/running lock was gone or + reassigned at the moment of this beat — i.e. a cancel/steer/kill interrupted this turn + since the last heartbeat. The runner's watchdog reads this to abort the in-flight run; + without it a cancel that raced a heartbeat's nx=True re-acquire would silently re-arm the + SAME lock under the SAME turn_id and the interruption would never surface. """ stream: Optional[SessionStream] = None replica_id: str + is_current_turn: bool = True diff --git a/api/oss/src/core/sessions/streams/interfaces.py b/api/oss/src/core/sessions/streams/interfaces.py index 873c13c598..b6f479e04e 100644 --- a/api/oss/src/core/sessions/streams/interfaces.py +++ b/api/oss/src/core/sessions/streams/interfaces.py @@ -6,8 +6,10 @@ SessionStream, SessionStreamCreate, SessionStreamEdit, + SessionStreamHeaderEdit, SessionStreamQuery, ) +from oss.src.core.shared.dtos import Windowing class SessionStreamsDAOInterface(ABC): @@ -28,6 +30,14 @@ async def get_by_session_id( session_id: str, ) -> Optional[SessionStream]: ... + @abstractmethod + async def get_by_session_id_including_archived( + self, + *, + project_id: UUID, + session_id: str, + ) -> Optional[SessionStream]: ... + @abstractmethod async def get_by_id( self, @@ -42,6 +52,8 @@ async def query( *, project_id: UUID, filter: SessionStreamQuery, + windowing: Optional[Windowing] = None, + session_ids: Optional[List[str]] = None, ) -> List[SessionStream]: ... @abstractmethod @@ -54,6 +66,16 @@ async def update( stream: SessionStreamEdit, ) -> Optional[SessionStream]: ... + @abstractmethod + async def update_header( + self, + *, + project_id: UUID, + user_id: Optional[UUID], + session_id: str, + header: SessionStreamHeaderEdit, + ) -> Optional[SessionStream]: ... + @abstractmethod async def delete_by_session_id( self, @@ -62,6 +84,23 @@ async def delete_by_session_id( session_id: str, ) -> bool: ... + @abstractmethod + async def unarchive_by_session_id( + self, + *, + project_id: UUID, + user_id: Optional[UUID], + session_id: str, + ) -> Optional[SessionStream]: ... + + @abstractmethod + async def hard_delete_by_session_id( + self, + *, + project_id: UUID, + session_id: str, + ) -> bool: ... + @abstractmethod async def count_active( self, diff --git a/api/oss/src/core/sessions/streams/runner_client.py b/api/oss/src/core/sessions/streams/runner_client.py new file mode 100644 index 0000000000..45e6689aca --- /dev/null +++ b/api/oss/src/core/sessions/streams/runner_client.py @@ -0,0 +1,62 @@ +"""Direct API -> runner HTTP hop, used only by `kill` (W7.3). + +Everything else in `core/sessions/` reaches the runner only indirectly, through the Redis +coordination plane (the runner heartbeats/reads locks the API wrote) or through the separate +invoke path (`WorkflowsService` -> the Python agent service -> the runner). `kill` is the one +verb that must reach the runner's OWN sandbox-teardown route (`POST /kill` on +`services/runner/src/server.ts`) directly, because dropping the Redis locks alone does not +tear down a warm sandbox — it only removes the coordination-plane bookkeeping. Without this +call, `kill` was Redis/row-only (see `service.py`'s `kill()` before this module existed) and +the runner's session-pool / in-flight sandbox kept running until its own idle TTL expired. + +Same base URL + shared-secret token the Python agent service already uses to reach the runner +(`services/oss/src/agent/config.py`'s `runner_url()`, `AGENTA_RUNNER_TOKEN` on both sides). +Best-effort: `env.runner.internal_url` unset means no direct hop is configured (e.g. a +dev/test composition running the runner as a bare subprocess with no HTTP surface), and any +call failure is swallowed — `kill`'s Redis/row edit must still succeed and be idempotent, and +the runner's own orphan sweep / idle-TTL eviction is the fallback net for a missed signal. +""" + +import httpx + +from oss.src.utils.env import env +from oss.src.utils.logging import get_module_logger + +log = get_module_logger(__name__) + +_KILL_TIMEOUT_SECONDS = 10.0 + + +async def kill_runner_sandbox(*, project_id: str, session_id: str) -> bool: + """POST the runner's `/kill`, scoped to (project_id, session_id). Returns True iff the + call was made and returned 2xx; False otherwise (not configured, network error, non-2xx). + Never raises — kill's Redis/row edit is the source of truth and must not be blocked by + the runner being unreachable. + """ + base_url = env.runner.internal_url + token = env.runner.token + if not base_url or not token: + log.debug( + "kill: no runner internal_url/token configured, skipping direct sandbox teardown" + ) + return False + + url = base_url.rstrip("/") + "/kill" + try: + async with httpx.AsyncClient(timeout=_KILL_TIMEOUT_SECONDS) as client: + response = await client.post( + url, + json={"sessionId": session_id, "projectId": project_id}, + headers={"Authorization": f"Bearer {token}"}, + ) + if response.status_code >= 300: + log.warning( + "kill: runner /kill returned %s for session=%s", + response.status_code, + session_id, + ) + return False + return True + except httpx.HTTPError as e: + log.warning("kill: runner /kill call failed for session=%s: %s", session_id, e) + return False diff --git a/api/oss/src/core/sessions/streams/service.py b/api/oss/src/core/sessions/streams/service.py index 402435202d..db2cf13311 100644 --- a/api/oss/src/core/sessions/streams/service.py +++ b/api/oss/src/core/sessions/streams/service.py @@ -4,11 +4,11 @@ session_streams row. Runs nothing itself: the runner (execution plane) is the only component that runs an agent. -Command matrix (prompt × force): - prompt + no force → SEND (409 if alive) - prompt + force → STEER (cancel holder, start a new turn) - no prompt + no f. → CANCEL (cancel holder, run nothing) - no prompt + force → ATTACH (steal attached, watch the live turn) +Command matrix (inputs/data × force): + inputs + no force → SEND (409 if alive) + inputs + force → STEER (cancel holder, start a new turn) + no inputs + no f. → CANCEL (cancel holder, run nothing) + no inputs + force → ATTACH (steal attached, watch the live turn) detach / kill → explicit lifecycle edits (see methods) """ @@ -47,6 +47,7 @@ SessionStreamCreate, SessionStreamEdit, SessionStreamFlags, + SessionStreamHeaderEdit, SessionStreamQuery, ) from oss.src.core.sessions.streams.types import ( @@ -56,6 +57,8 @@ SessionTurnInUse, ) from oss.src.core.sessions.streams.interfaces import SessionStreamsDAOInterface +from oss.src.core.sessions.streams.runner_client import kill_runner_sandbox +from oss.src.core.shared.dtos import Windowing log = get_module_logger(__name__) @@ -84,13 +87,13 @@ async def command( ) -> SessionStreamCommandResponse: _validate_session_id(request.session_id) - has_prompt = bool(request.prompt and request.prompt.strip()) + has_inputs = bool(request.data and request.data.inputs) - if has_prompt and not request.force: + if has_inputs and not request.force: mode = CommandMode.send - elif has_prompt and request.force: + elif has_inputs and request.force: mode = CommandMode.steer - elif not has_prompt and not request.force: + elif not has_inputs and not request.force: mode = CommandMode.cancel else: mode = CommandMode.attach @@ -203,11 +206,15 @@ async def kill( user_id: Optional[UUID], session_id: str, ) -> bool: - """KILL: collapse the whole nest and end the stream. + """KILL: tear down the sandbox and collapse the whole nest. KILL != CANCEL — cancel + only ends the current turn (the session/sandbox can resume); kill ends the session. Force-clears alive + running + attached + owner in Redis (losing the alive lock is - the runner's existing teardown signal), marks the row ended, and soft-deletes it. - Idempotent: a kill on an already-dead session is a no-op success. + the runner's existing teardown signal for its OWN in-process bookkeeping), calls the + runner's `/kill` directly so the actual sandbox is torn down rather than left to its + own idle-TTL eviction (W7.3 — a bare Redis/row edit is not sandbox teardown), marks the + row ended, and soft-deletes it. Idempotent: a kill on an already-dead session, or one + whose runner replica is unreachable, is still a no-op success (best-effort teardown). """ _validate_session_id(session_id) await force_cancel_alive( @@ -235,6 +242,9 @@ async def kill( session_id=session_id, watcher_id=throwaway, ) + # Best-effort: the Redis/row edit above is authoritative and must not depend on this + # succeeding — see runner_client.kill_runner_sandbox's docstring. + await kill_runner_sandbox(project_id=str(project_id), session_id=session_id) await self._mark_stream_ended( project_id=project_id, user_id=user_id, @@ -269,7 +279,29 @@ async def heartbeat( project_id=project_id, session_id=request.session_id, ) - return SessionHeartbeatResult(stream=stream, replica_id=owner) + return SessionHeartbeatResult( + stream=stream, replica_id=owner, is_current_turn=False + ) + + # True only when this turn_id still (or again, uninterrupted) owns the alive lock at + # the moment of this heartbeat. A cancel/steer/kill deletes the alive key entirely, + # which the nx=True re-acquire below would otherwise silently re-establish under the + # SAME turn_id, masking the interruption from the runner's watchdog (W7.4 — this is + # what `is_current_turn` exists to surface). An absent key is ambiguous by itself: it + # is also the normal state before this turn's VERY FIRST heartbeat (the API's + # `_start_turn` acquire may not have landed yet, or this beat wins a race with it), and + # that is NOT an interruption. Disambiguate with the durable row's `turn_id`: if it + # already recorded THIS turn_id as established (a prior heartbeat's write), the key + # being gone now is something else's doing; if the row shows no turn yet, or a + # different one, this is establishment. + prior_stream = await self._dao.get_by_session_id( + project_id=project_id, + session_id=request.session_id, + ) + turn_was_established = bool( + prior_stream and prior_stream.turn_id == request.turn_id + ) + is_current_turn = True if request.turn_id and request.is_running: # Acquire-then-refresh: the first heartbeat must establish the nest locks @@ -280,6 +312,8 @@ async def heartbeat( session_id=request.session_id, turn_id=request.turn_id, ): + if turn_was_established: + is_current_turn = False await acquire_alive( self._lock, project_id=str(project_id), @@ -292,6 +326,8 @@ async def heartbeat( session_id=request.session_id, turn_id=request.turn_id, ): + if turn_was_established: + is_current_turn = False await acquire_running( self._lock, project_id=str(project_id), @@ -314,10 +350,9 @@ async def heartbeat( is_attached=liveness["attached"], ) - stream = await self._dao.get_by_session_id( - project_id=project_id, - session_id=request.session_id, - ) + # Nothing between `prior_stream`'s fetch above and here mutates the row, so it is + # still the current read — no need to re-fetch. + stream = prior_stream if stream is None: try: @@ -344,6 +379,7 @@ async def heartbeat( return SessionHeartbeatResult( stream=stream, replica_id=owner, + is_current_turn=is_current_turn, ) async def fetch( @@ -372,15 +408,124 @@ async def fetch( stream.flags = flags return stream + async def fetch_header( + self, + *, + project_id: UUID, + session_id: str, + ) -> Optional[SessionStream]: + """Fetch the session header (name/description/flags/lifecycle) — used by + GET /sessions/streams/. Reads the same reconciled row as `fetch`. + """ + return await self.fetch(project_id=project_id, session_id=session_id) + + async def set_header( + self, + *, + project_id: UUID, + user_id: Optional[UUID], + session_id: str, + header: SessionStreamHeaderEdit, + ) -> Optional[SessionStream]: + """The rename edit: full-PUT {name, description} onto the merged stream row. + + Pure DB write — no Redis nest interaction, no flags/turn_id touched. Off the + runner's write path. Creates the row if the session has never heartbeat/run + yet (a caller may name a session before its first turn), mirroring + `_start_turn`'s create-or-update pattern. + """ + _validate_session_id(session_id) + updated = await self._dao.update_header( + project_id=project_id, + user_id=user_id, + session_id=session_id, + header=header, + ) + if updated is not None: + return updated + try: + return await self._dao.create( + project_id=project_id, + user_id=user_id, + stream=SessionStreamCreate( + session_id=session_id, + name=header.name, + description=header.description, + ), + ) + except SessionStreamAlreadyExists: + # A concurrent first touch (heartbeat/rename) won the race; the row now + # exists — apply the header edit onto it. + return await self._dao.update_header( + project_id=project_id, + user_id=user_id, + session_id=session_id, + header=header, + ) + async def query_streams( self, *, project_id: UUID, filter: SessionStreamQuery, + windowing: Optional[Windowing] = None, + session_ids: Optional[List[str]] = None, ) -> List[SessionStream]: if filter.session_id: _validate_session_id(filter.session_id) - return await self._dao.query(project_id=project_id, filter=filter) + return await self._dao.query( + project_id=project_id, + filter=filter, + windowing=windowing, + session_ids=session_ids, + ) + + async def hard_delete( + self, + *, + project_id: UUID, + session_id: str, + ) -> bool: + """Hard delete the merged stream row (S7 delete fan-out, WP5). Distinct + from `kill`, which only soft-deletes via `delete_by_session_id`.""" + _validate_session_id(session_id) + return await self._dao.hard_delete_by_session_id( + project_id=project_id, + session_id=session_id, + ) + + async def archive( + self, + *, + project_id: UUID, + session_id: str, + ) -> Optional[SessionStream]: + """Soft-archive the stream row (S7/F2 archive fan-out, WP5). Returns the + archived row (`deleted_at` set) as the caller's confirmation read.""" + _validate_session_id(session_id) + await self._dao.delete_by_session_id( + project_id=project_id, + session_id=session_id, + ) + return await self._dao.get_by_session_id_including_archived( + project_id=project_id, + session_id=session_id, + ) + + async def unarchive( + self, + *, + project_id: UUID, + user_id: Optional[UUID], + session_id: str, + ) -> Optional[SessionStream]: + """Reverse of `archive`: clears `deleted_at` on the stream row.""" + _validate_session_id(session_id) + return await self._dao.unarchive_by_session_id( + project_id=project_id, + user_id=user_id, + session_id=session_id, + ) async def check_runner_concurrency_limit(self, *, project_id: UUID) -> None: """Raise ConcurrencyLimitExceeded if the per-project limit is reached.""" diff --git a/api/oss/src/core/sessions/states/__init__.py b/api/oss/src/core/sessions/turns/__init__.py similarity index 100% rename from api/oss/src/core/sessions/states/__init__.py rename to api/oss/src/core/sessions/turns/__init__.py diff --git a/api/oss/src/core/sessions/turns/dtos.py b/api/oss/src/core/sessions/turns/dtos.py new file mode 100644 index 0000000000..cb9ac2c2ba --- /dev/null +++ b/api/oss/src/core/sessions/turns/dtos.py @@ -0,0 +1,54 @@ +from datetime import datetime +from typing import List, Optional +from uuid import UUID + +from pydantic import BaseModel + +from agenta.sdk.agents.dtos import HarnessKind + +from oss.src.core.shared.dtos import Identifier, Lifecycle, OTelSpanId, Reference + + +class SessionTurn(Identifier, Lifecycle): + project_id: UUID + session_id: str + turn_id: Optional[UUID] = None + stream_id: UUID + turn_index: int + harness_kind: HarnessKind + agent_session_id: Optional[str] = None + sandbox_id: Optional[str] = None + references: Optional[List[Reference]] = None + trace_id: Optional[UUID] = None + span_id: Optional[OTelSpanId] = None + start_time: Optional[datetime] = None + end_time: Optional[datetime] = None + + +class SessionTurnCreate(BaseModel): + session_id: str + turn_id: Optional[UUID] = None + stream_id: UUID + turn_index: int + harness_kind: HarnessKind + agent_session_id: Optional[str] = None + sandbox_id: Optional[str] = None + references: Optional[List[Reference]] = None + trace_id: Optional[UUID] = None + span_id: Optional[OTelSpanId] = None + start_time: Optional[datetime] = None + end_time: Optional[datetime] = None + + +class SessionTurnComplete(BaseModel): + session_id: str + turn_index: int + agent_session_id: Optional[str] = None + end_time: datetime + + +class SessionTurnQuery(BaseModel): + session_id: Optional[str] = None + stream_id: Optional[UUID] = None + harness_kind: Optional[HarnessKind] = None + references: Optional[List[Reference]] = None diff --git a/api/oss/src/core/sessions/turns/interfaces.py b/api/oss/src/core/sessions/turns/interfaces.py new file mode 100644 index 0000000000..30f4b20fc6 --- /dev/null +++ b/api/oss/src/core/sessions/turns/interfaces.py @@ -0,0 +1,77 @@ +from abc import ABC, abstractmethod +from typing import List, Optional +from uuid import UUID + +from oss.src.core.shared.dtos import Windowing +from oss.src.core.sessions.turns.dtos import ( + HarnessKind, + SessionTurn, + SessionTurnComplete, + SessionTurnCreate, + SessionTurnQuery, +) + + +class SessionTurnsDAOInterface(ABC): + @abstractmethod + async def append( + self, + *, + project_id: UUID, + user_id: Optional[UUID], + # + turn: SessionTurnCreate, + ) -> SessionTurn: ... + + @abstractmethod + async def complete( + self, + *, + project_id: UUID, + # + turn: SessionTurnComplete, + ) -> Optional[SessionTurn]: ... + + @abstractmethod + async def fetch_turn( + self, + *, + project_id: UUID, + # + turn_id: UUID, + ) -> Optional[SessionTurn]: ... + + @abstractmethod + async def query_turns( + self, + *, + project_id: UUID, + # + query: Optional[SessionTurnQuery] = None, + windowing: Optional[Windowing] = None, + ) -> List[SessionTurn]: ... + + @abstractmethod + async def latest_turn( + self, + *, + project_id: UUID, + session_id: str, + ) -> Optional[SessionTurn]: ... + + @abstractmethod + async def latest_turn_per_harness_kind( + self, + *, + project_id: UUID, + session_id: str, + harness_kind: HarnessKind, + ) -> Optional[SessionTurn]: ... + + @abstractmethod + async def delete_by_session_id( + self, + *, + project_id: UUID, + session_id: str, + ) -> int: ... diff --git a/api/oss/src/core/sessions/turns/service.py b/api/oss/src/core/sessions/turns/service.py new file mode 100644 index 0000000000..068d219911 --- /dev/null +++ b/api/oss/src/core/sessions/turns/service.py @@ -0,0 +1,122 @@ +"""Session turns service — one row per turn, the transcript twin of a trace. + +The latest turn's agent_session_id / sandbox_id IS the current resume pointer (a +query, not a stored fold — a late lower-index write can never win ORDER BY +turn_index DESC LIMIT 1). +""" + +from typing import List, Optional +from uuid import UUID + +from oss.src.core.shared.dtos import Windowing +from oss.src.core.sessions.turns.dtos import ( + HarnessKind, + SessionTurn, + SessionTurnComplete, + SessionTurnCreate, + SessionTurnQuery, +) +from oss.src.core.sessions.turns.interfaces import SessionTurnsDAOInterface +from oss.src.core.sessions.turns.types import SessionTurnNotFound + + +class SessionTurnsService: + def __init__( + self, + *, + turns_dao: SessionTurnsDAOInterface, + ) -> None: + self._dao = turns_dao + + async def append_turn( + self, + *, + project_id: UUID, + user_id: Optional[UUID], + # + turn: SessionTurnCreate, + ) -> SessionTurn: + return await self._dao.append( + project_id=project_id, + user_id=user_id, + turn=turn, + ) + + async def fetch_turn( + self, + *, + project_id: UUID, + # + turn_id: UUID, + ) -> Optional[SessionTurn]: + return await self._dao.fetch_turn( + project_id=project_id, + turn_id=turn_id, + ) + + async def complete_turn( + self, + *, + project_id: UUID, + # + turn: SessionTurnComplete, + ) -> SessionTurn: + completed = await self._dao.complete( + project_id=project_id, + turn=turn, + ) + if completed is None: + raise SessionTurnNotFound(turn.session_id, turn.turn_index) + return completed + + async def query_turns( + self, + *, + project_id: UUID, + # + query: Optional[SessionTurnQuery] = None, + windowing: Optional[Windowing] = None, + ) -> List[SessionTurn]: + return await self._dao.query_turns( + project_id=project_id, + query=query, + windowing=windowing, + ) + + async def latest_turn( + self, + *, + project_id: UUID, + session_id: str, + ) -> Optional[SessionTurn]: + """The resume-read: latest_turn_index / agent_session_id / sandbox_id are this row.""" + return await self._dao.latest_turn( + project_id=project_id, + session_id=session_id, + ) + + async def latest_turn_per_harness_kind( + self, + *, + project_id: UUID, + session_id: str, + harness_kind: HarnessKind, + ) -> Optional[SessionTurn]: + """The per-harness-kind resume-read.""" + return await self._dao.latest_turn_per_harness_kind( + project_id=project_id, + session_id=session_id, + harness_kind=harness_kind, + ) + + async def delete_by_session_id( + self, + *, + project_id: UUID, + session_id: str, + ) -> int: + """Hard delete every turn for a session (S7 delete fan-out, WP5).""" + return await self._dao.delete_by_session_id( + project_id=project_id, + session_id=session_id, + ) diff --git a/api/oss/src/core/sessions/turns/types.py b/api/oss/src/core/sessions/turns/types.py new file mode 100644 index 0000000000..ed503eacfa --- /dev/null +++ b/api/oss/src/core/sessions/turns/types.py @@ -0,0 +1,13 @@ +"""Domain exceptions for session turns.""" + + +class SessionTurnError(Exception): + """Base exception for session turn errors.""" + + +class SessionTurnNotFound(SessionTurnError): + def __init__(self, session_id: str, turn_index: int): + self.session_id = session_id + self.turn_index = turn_index + self.message = f"No turn {turn_index} found for session '{session_id}'." + super().__init__(self.message) diff --git a/api/oss/src/core/shared/dtos.py b/api/oss/src/core/shared/dtos.py index ab3b1ebc1f..3f45cc4264 100644 --- a/api/oss/src/core/shared/dtos.py +++ b/api/oss/src/core/shared/dtos.py @@ -1,8 +1,8 @@ from datetime import datetime, timezone -from typing import Optional +from typing import Annotated, Optional from uuid import UUID -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, StringConstraints from agenta.sdk.models.shared import ( # noqa: F401 BoolJson, @@ -51,6 +51,12 @@ ) +# An OpenTelemetry span id: 64-bit, exactly 16 hex chars. NOT a UUID (which is 128-bit / +# 32 hex). Typing this as UUID rejected every runner record-ingest with a 422 (16 vs 32 hex). +# Trace ids ARE 128-bit (32 hex) and correctly continue to ride as UUID. +OTelSpanId = Annotated[str, StringConstraints(pattern=r"^[0-9a-fA-F]{16}$")] + + class Status(BaseModel): timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) type: Optional[str] = None diff --git a/api/oss/src/core/tracing/dtos.py b/api/oss/src/core/tracing/dtos.py index dfa5b56799..d59afb4a43 100644 --- a/api/oss/src/core/tracing/dtos.py +++ b/api/oss/src/core/tracing/dtos.py @@ -94,6 +94,9 @@ class Fields(str, Enum): UPDATED_BY_ID = "updated_by_id" DELETED_BY_ID = "deleted_by_id" CONTENT = "content" + SESSION_ID = "session_id" + USER_ID = "user_id" + AGENT_ID = "agent_id" class LogicalOperator(str, Enum): diff --git a/api/oss/src/core/tracing/service.py b/api/oss/src/core/tracing/service.py index 39566d6001..a0d06a8c35 100644 --- a/api/oss/src/core/tracing/service.py +++ b/api/oss/src/core/tracing/service.py @@ -23,6 +23,7 @@ from oss.src.core.tracing.utils.trees import ( calculate_and_propagate_metrics_by_trace, infer_and_propagate_trace_type_by_trace, + promote_identity_by_trace, trace_map_to_traces, ) from oss.src.core.tracing.streaming import publish_spans @@ -150,6 +151,14 @@ async def ingest_span_dtos( exc_info=True, ) + try: + span_dtos = promote_identity_by_trace(span_dtos) + except Exception: # pylint: disable=broad-exception-caught + log.error( + "Failed to promote session/user/agent identity; continuing without it", + exc_info=True, + ) + await publish_spans( organization_id=organization_id, project_id=project_id, diff --git a/api/oss/src/core/tracing/utils/filtering.py b/api/oss/src/core/tracing/utils/filtering.py index 1c730239d6..7412f1582a 100644 --- a/api/oss/src/core/tracing/utils/filtering.py +++ b/api/oss/src/core/tracing/utils/filtering.py @@ -533,6 +533,12 @@ def parse_condition( _parse_uuid_field_condition(condition) elif condition.field == Fields.CONTENT: _parse_fts_field_condition(condition) + elif condition.field == Fields.SESSION_ID: + _parse_string_field_condition(condition) + elif condition.field == Fields.USER_ID: + _parse_string_field_condition(condition) + elif condition.field == Fields.AGENT_ID: + _parse_string_field_condition(condition) else: # raise FilteringException( # f"Unsupported condition field '{condition.field}'.", diff --git a/api/oss/src/core/tracing/utils/trees.py b/api/oss/src/core/tracing/utils/trees.py index eca466d41b..adf7181524 100644 --- a/api/oss/src/core/tracing/utils/trees.py +++ b/api/oss/src/core/tracing/utils/trees.py @@ -140,6 +140,38 @@ def infer_and_propagate_trace_type_by_trace( return span_dtos +def promote_identity_by_trace( + span_dtos: List[OTelFlatSpan], +) -> List[OTelFlatSpan]: + """ + Lift session/user/agent identity from the root span's attributes onto its + own session_id/user_id/agent_id columns. + + Root-only: ingestion is span-by-span / partial-batch, so there is no cheap + way to propagate identity from a root to children arriving in a different + request. Children are left untouched (nullable columns). + """ + if not span_dtos: + return span_dtos + + for span_dto in span_dtos: + if span_dto.parent_id is not None: + continue + + attributes = span_dto.attributes or {} + ag = attributes.get("ag") or {} + + session = ag.get("session") or {} + user = ag.get("user") or {} + agent = ag.get("agent") or {} + + span_dto.session_id = session.get("id") if isinstance(session, dict) else None + span_dto.user_id = user.get("id") if isinstance(user, dict) else None + span_dto.agent_id = agent.get("id") if isinstance(agent, dict) else None + + return span_dtos + + def parse_span_idx_to_span_id_tree( span_idx: Dict[str, OTelFlatSpan], ) -> OrderedDict: diff --git a/api/oss/src/dbs/postgres/mounts/dao.py b/api/oss/src/dbs/postgres/mounts/dao.py index d43bc9bd63..6bb8dd3b86 100644 --- a/api/oss/src/dbs/postgres/mounts/dao.py +++ b/api/oss/src/dbs/postgres/mounts/dao.py @@ -2,7 +2,7 @@ from typing import List, Optional from uuid import UUID -from sqlalchemy import select +from sqlalchemy import delete as sa_delete, select from sqlalchemy.exc import IntegrityError from sqlalchemy.dialects.postgresql import insert @@ -226,6 +226,36 @@ async def unarchive_mount( return map_mount_dbe_to_dto(mount_dbe=mount_dbe) + async def delete_by_session_id( + self, + *, + project_id: UUID, + session_id: str, + ) -> List[Mount]: + """Hard delete the mount rows bound to a session. Mounts are semi- + independent (optional `session_id`, may outlive a session) — this is + the explicit, session-scoped fan-out (S7/F1, WP5), not a blind + cascade. Returns the deleted rows so the caller can tear down their + object-store prefixes.""" + async with self.engine.session() as session: + stmt = select(MountDBE).where( + MountDBE.project_id == project_id, + MountDBE.session_id == session_id, + ) + result = await session.execute(stmt) + mount_dbes = list(result.scalars().all()) + mounts = [map_mount_dbe_to_dto(mount_dbe=dbe) for dbe in mount_dbes] + + if mount_dbes: + del_stmt = sa_delete(MountDBE).where( + MountDBE.project_id == project_id, + MountDBE.session_id == session_id, + ) + await session.execute(del_stmt) + await session.commit() + + return mounts + async def query_mounts( self, *, @@ -247,6 +277,9 @@ async def query_mounts( if mount_query.session_id is not None: stmt = stmt.where(MountDBE.session_id == mount_query.session_id) + if mount_query.agent_id is not None: + stmt = stmt.where(MountDBE.agent_id == mount_query.agent_id) + else: stmt = stmt.where(MountDBE.deleted_at.is_(None)) diff --git a/api/oss/src/dbs/postgres/mounts/dbas.py b/api/oss/src/dbs/postgres/mounts/dbas.py index 6ea201466b..464c388514 100644 --- a/api/oss/src/dbs/postgres/mounts/dbas.py +++ b/api/oss/src/dbs/postgres/mounts/dbas.py @@ -31,3 +31,10 @@ class MountDBA( String, nullable=True, ) + + # agent_id is a bare column — not an FK. Populated only for agent mounts, + # mirroring session_id (populated only for session mounts). + agent_id = Column( + String, + nullable=True, + ) diff --git a/api/oss/src/dbs/postgres/mounts/dbes.py b/api/oss/src/dbs/postgres/mounts/dbes.py index 0e6e2c280b..7616f4746a 100644 --- a/api/oss/src/dbs/postgres/mounts/dbes.py +++ b/api/oss/src/dbs/postgres/mounts/dbes.py @@ -41,4 +41,10 @@ class MountDBE(Base, MountDBA): "session_id", postgresql_where=text("session_id IS NOT NULL"), ), + Index( + "ix_mounts_project_id_agent_id", + "project_id", + "agent_id", + postgresql_where=text("agent_id IS NOT NULL"), + ), ) diff --git a/api/oss/src/dbs/postgres/mounts/mappings.py b/api/oss/src/dbs/postgres/mounts/mappings.py index 1ab7f915fd..529ed8899f 100644 --- a/api/oss/src/dbs/postgres/mounts/mappings.py +++ b/api/oss/src/dbs/postgres/mounts/mappings.py @@ -38,6 +38,7 @@ def map_mount_dto_to_dbe_upsert( "deleted_by_id": None, "slug": mount_create.slug, "session_id": mount_create.session_id, + "agent_id": mount_create.agent_id, "name": mount_create.name, "description": mount_create.description, "flags": mount_create.flags.model_dump(), @@ -61,6 +62,7 @@ def map_mount_dto_to_dbe_create( # slug=mount_create.slug, session_id=mount_create.session_id, + agent_id=mount_create.agent_id, # name=mount_create.name, description=mount_create.description, @@ -90,6 +92,7 @@ def map_mount_dbe_to_dto( project_id=mount_dbe.project_id, slug=mount_dbe.slug, session_id=mount_dbe.session_id, + agent_id=mount_dbe.agent_id, # name=mount_dbe.name, description=mount_dbe.description, diff --git a/api/oss/src/dbs/postgres/sessions/interactions/dao.py b/api/oss/src/dbs/postgres/sessions/interactions/dao.py index ab53c880d3..33f8b0a159 100644 --- a/api/oss/src/dbs/postgres/sessions/interactions/dao.py +++ b/api/oss/src/dbs/postgres/sessions/interactions/dao.py @@ -2,7 +2,8 @@ from typing import List, Optional from uuid import UUID -from sqlalchemy import func, select, update as sa_update +from sqlalchemy import cast, delete as sa_delete, func, select, update as sa_update +from sqlalchemy.dialects.postgresql import JSON, JSONB, array from sqlalchemy.exc import IntegrityError from oss.src.core.sessions.interactions.dtos import ( @@ -97,6 +98,24 @@ async def transition_interaction( # Only non-terminal interactions transition: pending (responded|resolved| # cancelled) and responded (resolved, when the runner consumes an API-plane # answer). resolved/cancelled are terminal. + transition_values = { + "status": transition.status.value, + "updated_at": datetime.now(timezone.utc), + } + if transition.resolution is not None: + # Preserve request/references while atomically adding the answer under the guard. + transition_values["data"] = cast( + func.jsonb_set( + func.coalesce( + cast(SessionInteractionDBE.data, JSONB), + cast({}, JSONB), + ), + array(["resolution"]), + cast(transition.resolution, JSONB), + True, + ), + JSON, + ) stmt = ( sa_update(SessionInteractionDBE) .where( @@ -105,10 +124,7 @@ async def transition_interaction( SessionInteractionDBE.token == transition.token, SessionInteractionDBE.status.in_(("pending", "responded")), ) - .values( - status=transition.status.value, - updated_at=datetime.now(timezone.utc), - ) + .values(**transition_values) .returning(SessionInteractionDBE) ) result = await session.execute(stmt) @@ -208,3 +224,20 @@ async def query_interactions( result = await session.execute(stmt) return [map_interaction_dbe_to_dto(dbe) for dbe in result.scalars().all()] + + async def delete_by_session_id( + self, + *, + project_id: UUID, + session_id: str, + ) -> int: + """Hard delete — no soft-delete for interactions today (session-scoped + fan-out, WP5).""" + async with self.engine.session() as session: + stmt = sa_delete(SessionInteractionDBE).where( + SessionInteractionDBE.project_id == project_id, + SessionInteractionDBE.session_id == session_id, + ) + result = await session.execute(stmt) + await session.commit() + return result.rowcount or 0 diff --git a/api/oss/src/dbs/postgres/sessions/records/dao.py b/api/oss/src/dbs/postgres/sessions/records/dao.py index 2ec9b47d37..93ff6ed548 100644 --- a/api/oss/src/dbs/postgres/sessions/records/dao.py +++ b/api/oss/src/dbs/postgres/sessions/records/dao.py @@ -91,6 +91,8 @@ def _upsert_stmt(*, values_list: List[dict]): "record_source": stmt.excluded.record_source, "timestamp": stmt.excluded.timestamp, "attributes": stmt.excluded.attributes, + "turn_id": stmt.excluded.turn_id, + "span_id": stmt.excluded.span_id, }, ).returning(RecordDBE) diff --git a/api/oss/src/dbs/postgres/sessions/records/dbas.py b/api/oss/src/dbs/postgres/sessions/records/dbas.py index 1345bd2bde..200eae44bb 100644 --- a/api/oss/src/dbs/postgres/sessions/records/dbas.py +++ b/api/oss/src/dbs/postgres/sessions/records/dbas.py @@ -4,6 +4,23 @@ from sqlalchemy.dialects.postgresql import JSONB +class RecordTurnSpanDBA: + __abstract__ = True + + # Plain columns, no FK (cross-DB: turns live in the core DB, spans elsewhere in the + # tracing DB). Forward-fill only — the tracing DB is never backfilled/data-migrated, so + # both stay null on rows written before this column existed. + turn_id = Column( + String, + nullable=True, + ) + # OTel span id (16 hex chars), NOT a UUID — stored as text (see OTelSpanId). + span_id = Column( + String, + nullable=True, + ) + + class RecordDBA: __abstract__ = True diff --git a/api/oss/src/dbs/postgres/sessions/records/dbes.py b/api/oss/src/dbs/postgres/sessions/records/dbes.py index 2c312f6e4c..78de94b5df 100644 --- a/api/oss/src/dbs/postgres/sessions/records/dbes.py +++ b/api/oss/src/dbs/postgres/sessions/records/dbes.py @@ -1,7 +1,7 @@ from sqlalchemy import PrimaryKeyConstraint, Index from oss.src.dbs.postgres.shared.base import Base -from oss.src.dbs.postgres.sessions.records.dbas import RecordDBA +from oss.src.dbs.postgres.sessions.records.dbas import RecordDBA, RecordTurnSpanDBA from oss.src.dbs.postgres.shared.dbas import ProjectScopeDBA, LifecycleDBA @@ -10,6 +10,7 @@ class RecordDBE( ProjectScopeDBA, LifecycleDBA, RecordDBA, + RecordTurnSpanDBA, ): __tablename__ = "records" @@ -26,4 +27,10 @@ class RecordDBE( "attributes", postgresql_using="gin", ), + Index( + "ix_records_project_id_session_id_turn_id", + "project_id", + "session_id", + "turn_id", + ), ) diff --git a/api/oss/src/dbs/postgres/sessions/records/mappings.py b/api/oss/src/dbs/postgres/sessions/records/mappings.py index e38a1570de..62420cc97e 100644 --- a/api/oss/src/dbs/postgres/sessions/records/mappings.py +++ b/api/oss/src/dbs/postgres/sessions/records/mappings.py @@ -23,6 +23,8 @@ def map_record_event_to_dbe( record_type=event.record_type, record_source=event.record_source, attributes=event.attributes, + turn_id=event.turn_id, + span_id=event.span_id, ) @@ -36,5 +38,7 @@ def map_record_dbe_to_dto(*, dbe: RecordDBE) -> SessionRecord: record_type=dbe.record_type, record_source=dbe.record_source, attributes=dbe.attributes, + turn_id=dbe.turn_id, + span_id=dbe.span_id, created_at=dbe.created_at, ) diff --git a/api/oss/src/dbs/postgres/sessions/states/dao.py b/api/oss/src/dbs/postgres/sessions/states/dao.py deleted file mode 100644 index 3e2c14843f..0000000000 --- a/api/oss/src/dbs/postgres/sessions/states/dao.py +++ /dev/null @@ -1,128 +0,0 @@ -from datetime import datetime, timezone -from typing import Optional -from uuid import UUID - -import uuid_utils.compat as uuid_utils - -from sqlalchemy import Integer, case, cast, func, select -from sqlalchemy.dialects.postgresql import insert - -from oss.src.utils.logging import get_module_logger -from oss.src.utils.exceptions import suppress_exceptions - -from oss.src.core.sessions.states.interfaces import SessionStatesDAOInterface -from oss.src.core.sessions.states.dtos import ( - SessionState, - SessionStateFlags, - SessionStateUpsert, -) - -from oss.src.dbs.postgres.shared.engine import ( - TransactionsEngine, - get_transactions_engine, -) -from oss.src.dbs.postgres.sessions.states.dbes import SessionStateDBE -from oss.src.dbs.postgres.sessions.states.mappings import dbe_to_dto - -log = get_module_logger(__name__) - - -class SessionStatesDAO(SessionStatesDAOInterface): - def __init__(self, engine: TransactionsEngine = None): - if engine is None: - engine = get_transactions_engine() - self.engine = engine - - @suppress_exceptions() - async def get_session_state( - self, - *, - project_id: UUID, - session_id: str, - ) -> Optional[SessionState]: - async with self.engine.session() as session: - stmt = ( - select(SessionStateDBE) - .filter(SessionStateDBE.project_id == project_id) - .filter(SessionStateDBE.session_id == session_id) - .limit(1) - ) - result = await session.execute(stmt) - dbe = result.scalars().first() - if dbe is None: - return None - return dbe_to_dto(dbe) - - @suppress_exceptions() - async def set_session_state( - self, - *, - project_id: UUID, - user_id: UUID, - session_id: str, - upsert: SessionStateUpsert, - ) -> Optional[SessionState]: - now = datetime.now(timezone.utc) - - data_json = ( - upsert.data.model_dump( - mode="json", - ) - if upsert.data is not None - else None - ) - - values = { - "id": uuid_utils.uuid7(), - "project_id": project_id, - "session_id": session_id, - "data": data_json, - "sandbox_id": upsert.sandbox_id, - "flags": SessionStateFlags().model_dump(mode="json"), - "created_at": now, - "updated_at": None, - "created_by_id": user_id, - "updated_by_id": None, - "deleted_at": None, - "deleted_by_id": None, - } - - stmt = insert(SessionStateDBE).values(**values) - update_values = { - "updated_at": now, - "updated_by_id": user_id, - } - if "data" in upsert.model_fields_set: - update_values["data"] = stmt.excluded.data - guarded_pointer_write = ( - "sandbox_id" in upsert.model_fields_set - and upsert.sandbox_turn_index is not None - ) - if guarded_pointer_write: - pointer_write_allowed = ( - func.coalesce( - cast(SessionStateDBE.data["latest_turn_index"].astext, Integer), - -1, - ) - <= upsert.sandbox_turn_index - ) - update_values["sandbox_id"] = case( - (pointer_write_allowed, stmt.excluded.sandbox_id), - else_=SessionStateDBE.sandbox_id, - ) - elif "sandbox_id" in upsert.model_fields_set: - update_values["sandbox_id"] = stmt.excluded.sandbox_id - - stmt = stmt.on_conflict_do_update( - constraint="uq_session_states_project_session_id", - set_=update_values, - ) - stmt = stmt.returning(SessionStateDBE) - - async with self.engine.session() as db_session: - result = await db_session.execute(stmt) - await db_session.commit() - dbe = result.scalars().first() - if dbe is None: - return None - return dbe_to_dto(dbe) diff --git a/api/oss/src/dbs/postgres/sessions/states/dbes.py b/api/oss/src/dbs/postgres/sessions/states/dbes.py deleted file mode 100644 index b054f16e33..0000000000 --- a/api/oss/src/dbs/postgres/sessions/states/dbes.py +++ /dev/null @@ -1,51 +0,0 @@ -from sqlalchemy import ( - Column, - String, - PrimaryKeyConstraint, - UniqueConstraint, - ForeignKeyConstraint, -) - -from oss.src.dbs.postgres.shared.base import Base -from oss.src.dbs.postgres.shared.dbas import ( - IdentifierDBA, - ProjectScopeDBA, - DataDBA, - FlagsDBA, - LifecycleDBA, - MetaDBA, - TagsDBA, -) - - -class SessionStateDBE( - Base, - ProjectScopeDBA, - IdentifierDBA, - DataDBA, - FlagsDBA, - TagsDBA, - MetaDBA, - LifecycleDBA, -): - __tablename__ = "session_states" - - __table_args__ = ( - PrimaryKeyConstraint("project_id", "id"), - ForeignKeyConstraint( - ["project_id"], - ["projects.id"], - ondelete="CASCADE", - ), - UniqueConstraint( - "project_id", - "session_id", - name="uq_session_states_project_session_id", - ), - ) - - # bare correlator — not an FK; sessions may be external - session_id = Column(String, nullable=False) - - # resume pointer: which sandbox to reconnect (null = no live sandbox) - sandbox_id = Column(String, nullable=True) diff --git a/api/oss/src/dbs/postgres/sessions/states/mappings.py b/api/oss/src/dbs/postgres/sessions/states/mappings.py deleted file mode 100644 index a15712475a..0000000000 --- a/api/oss/src/dbs/postgres/sessions/states/mappings.py +++ /dev/null @@ -1,52 +0,0 @@ -from typing import Optional - -from pydantic import ValidationError - -from oss.src.utils.logging import get_module_logger -from oss.src.core.sessions.states.dtos import ( - SessionState, - SessionStateData, - SessionStateFlags, -) -from oss.src.dbs.postgres.sessions.states.dbes import SessionStateDBE - -log = get_module_logger(__name__) - - -def _parse_state_data(dbe: SessionStateDBE) -> Optional[SessionStateData]: - if not dbe.data: - return None - - try: - return SessionStateData.model_validate(dbe.data) - except ValidationError as e: - log.error( - "[SESSION_STATES] Corrupt session_states.data; degrading to None", - session_id=dbe.session_id, - project_id=dbe.project_id, - raw_data=dbe.data, - error=str(e), - ) - return None - - -def dbe_to_dto(dbe: SessionStateDBE) -> SessionState: - data = _parse_state_data(dbe) - return SessionState( - id=dbe.id, - project_id=dbe.project_id, - session_id=dbe.session_id, - data=data, - sandbox_id=dbe.sandbox_id, - flags=SessionStateFlags.model_validate(dbe.flags) - if dbe.flags - else SessionStateFlags(), - tags=dbe.tags, - meta=dbe.meta, - created_at=dbe.created_at, - updated_at=dbe.updated_at, - deleted_at=dbe.deleted_at, - created_by_id=dbe.created_by_id, - updated_by_id=dbe.updated_by_id, - deleted_by_id=dbe.deleted_by_id, - ) diff --git a/api/oss/src/dbs/postgres/sessions/streams/dao.py b/api/oss/src/dbs/postgres/sessions/streams/dao.py index d2f997c38b..e23f37f2ac 100644 --- a/api/oss/src/dbs/postgres/sessions/streams/dao.py +++ b/api/oss/src/dbs/postgres/sessions/streams/dao.py @@ -2,27 +2,31 @@ from typing import List, Optional from uuid import UUID -from sqlalchemy import func, select +from sqlalchemy import delete as sa_delete, func, select from sqlalchemy.exc import IntegrityError from oss.src.core.sessions.streams.dtos import ( SessionStream, SessionStreamCreate, SessionStreamEdit, + SessionStreamHeaderEdit, SessionStreamQuery, ) from oss.src.core.sessions.streams.interfaces import SessionStreamsDAOInterface from oss.src.core.sessions.streams.types import SessionStreamAlreadyExists +from oss.src.core.shared.dtos import Windowing from oss.src.dbs.postgres.shared.engine import ( TransactionsEngine, get_transactions_engine, ) +from oss.src.dbs.postgres.shared.utils import apply_windowing from oss.src.dbs.postgres.sessions.streams.dbes import SessionStreamDBE from oss.src.dbs.postgres.sessions.streams.mappings import ( map_stream_dbe_to_dto, map_stream_dto_to_dbe_create, map_stream_dto_to_dbe_edit, + map_stream_dto_to_dbe_header_edit, ) @@ -74,6 +78,25 @@ async def get_by_session_id( return None return map_stream_dbe_to_dto(stream_dbe=dbe) + async def get_by_session_id_including_archived( + self, + *, + project_id: UUID, + session_id: str, + ) -> Optional[SessionStream]: + """Like `get_by_session_id`, but also returns a soft-archived row — the + confirmation read for `archive`/`unarchive` (S7/F2, WP5).""" + async with self.engine.session() as session: + stmt = select(SessionStreamDBE).where( + SessionStreamDBE.project_id == project_id, + SessionStreamDBE.session_id == session_id, + ) + result = await session.execute(stmt) + dbe = result.scalar_one_or_none() + if dbe is None: + return None + return map_stream_dbe_to_dto(stream_dbe=dbe) + async def get_by_id( self, *, @@ -97,6 +120,8 @@ async def query( *, project_id: UUID, filter: SessionStreamQuery, + windowing: Optional[Windowing] = None, + session_ids: Optional[List[str]] = None, ) -> List[SessionStream]: async with self.engine.session() as session: stmt = select(SessionStreamDBE).where( @@ -105,13 +130,24 @@ async def query( ) if filter.session_id is not None: stmt = stmt.where(SessionStreamDBE.session_id == filter.session_id) + if session_ids is not None: + stmt = stmt.where(SessionStreamDBE.session_id.in_(session_ids)) if filter.flags is not None: flags_filter = filter.flags.model_dump( exclude_none=True, exclude_unset=True ) if flags_filter: stmt = stmt.where(SessionStreamDBE.flags.contains(flags_filter)) - stmt = stmt.order_by(SessionStreamDBE.created_at.desc()) + if windowing: + stmt = apply_windowing( + stmt=stmt, + DBE=SessionStreamDBE, + attribute="id", + order="descending", + windowing=windowing, + ) + else: + stmt = stmt.order_by(SessionStreamDBE.created_at.desc()) result = await session.execute(stmt) dbes = result.scalars().all() return [map_stream_dbe_to_dto(stream_dbe=dbe) for dbe in dbes] @@ -144,6 +180,34 @@ async def update( await session.refresh(dbe) return map_stream_dbe_to_dto(stream_dbe=dbe) + async def update_header( + self, + *, + project_id: UUID, + user_id: Optional[UUID], + session_id: str, + header: SessionStreamHeaderEdit, + ) -> Optional[SessionStream]: + async with self.engine.session() as session: + stmt = select(SessionStreamDBE).where( + SessionStreamDBE.project_id == project_id, + SessionStreamDBE.session_id == session_id, + SessionStreamDBE.deleted_at.is_(None), + ) + result = await session.execute(stmt) + dbe = result.scalar_one_or_none() + if dbe is None: + return None + map_stream_dto_to_dbe_header_edit( + stream_dbe=dbe, + user_id=user_id, + header=header, + ) + dbe.updated_at = datetime.now(timezone.utc) + await session.commit() + await session.refresh(dbe) + return map_stream_dbe_to_dto(stream_dbe=dbe) + async def delete_by_session_id( self, *, @@ -164,6 +228,49 @@ async def delete_by_session_id( await session.commit() return True + async def unarchive_by_session_id( + self, + *, + project_id: UUID, + user_id: Optional[UUID], + session_id: str, + ) -> Optional[SessionStream]: + """Clear `deleted_at` on the stream row — the reverse of the archive + fan-out's `delete_by_session_id` soft-delete (S7/F2, WP5).""" + async with self.engine.session() as session: + stmt = select(SessionStreamDBE).where( + SessionStreamDBE.project_id == project_id, + SessionStreamDBE.session_id == session_id, + ) + result = await session.execute(stmt) + dbe = result.scalar_one_or_none() + if dbe is None: + return None + dbe.deleted_at = None + dbe.updated_by_id = user_id + dbe.updated_at = datetime.now(timezone.utc) + await session.commit() + await session.refresh(dbe) + return map_stream_dbe_to_dto(stream_dbe=dbe) + + async def hard_delete_by_session_id( + self, + *, + project_id: UUID, + session_id: str, + ) -> bool: + """Hard delete the merged stream row — `kill`/`delete_by_session_id` only + soft-delete; this is new plumbing for the session-scoped hard-delete + fan-out (S7/F1, WP5).""" + async with self.engine.session() as session: + stmt = sa_delete(SessionStreamDBE).where( + SessionStreamDBE.project_id == project_id, + SessionStreamDBE.session_id == session_id, + ) + result = await session.execute(stmt) + await session.commit() + return bool(result.rowcount) + async def count_active( self, *, diff --git a/api/oss/src/dbs/postgres/sessions/streams/dbes.py b/api/oss/src/dbs/postgres/sessions/streams/dbes.py index 00a0ad0602..a22ed8f4f7 100644 --- a/api/oss/src/dbs/postgres/sessions/streams/dbes.py +++ b/api/oss/src/dbs/postgres/sessions/streams/dbes.py @@ -10,6 +10,7 @@ from oss.src.dbs.postgres.shared.base import Base from oss.src.dbs.postgres.shared.dbas import ( FlagsDBA, + HeaderDBA, IdentifierDBA, LifecycleDBA, MetaDBA, @@ -22,18 +23,21 @@ class SessionStreamDBE( Base, IdentifierDBA, ProjectScopeDBA, + HeaderDBA, LifecycleDBA, FlagsDBA, TagsDBA, MetaDBA, ): - """Ephemeral run/liveness facet for a session — the durable mirror of the - Redis nest (alive ⊇ running ⊇ attached). + """The session's one row: identity (name/description) and run/liveness (the + durable mirror of the Redis nest, alive ⊇ running ⊇ attached). 1:1 with session_id per project. Redis is authoritative for the nest bools; this row mirrors them in ``flags`` for durability / orphan sweep / observability. ``updated_at`` (LifecycleDBA) is the heartbeat timestamp — no separate column. - sandbox_id is NOT stored here (it lives in session_states). + ``name``/``description`` (HeaderDBA) are written only on the rename edit, never + on a flag-mirror write, so heartbeats don't churn them. + sandbox_id is NOT stored here (it lives on the latest session_turns row). """ __tablename__ = "session_streams" diff --git a/api/oss/src/dbs/postgres/sessions/streams/mappings.py b/api/oss/src/dbs/postgres/sessions/streams/mappings.py index 00d5a1d7ff..5053fc4091 100644 --- a/api/oss/src/dbs/postgres/sessions/streams/mappings.py +++ b/api/oss/src/dbs/postgres/sessions/streams/mappings.py @@ -6,6 +6,7 @@ SessionStreamCreate, SessionStreamEdit, SessionStreamFlags, + SessionStreamHeaderEdit, ) from oss.src.dbs.postgres.sessions.streams.dbes import SessionStreamDBE @@ -20,6 +21,8 @@ def map_stream_dto_to_dbe_create( project_id=project_id, created_by_id=user_id, session_id=stream.session_id, + name=stream.name, + description=stream.description, flags=stream.flags.model_dump(mode="json") if stream.flags else None, tags=stream.tags, meta=stream.meta, @@ -41,6 +44,8 @@ def map_stream_dbe_to_dto( deleted_by_id=stream_dbe.deleted_by_id, project_id=stream_dbe.project_id, session_id=stream_dbe.session_id, + name=stream_dbe.name, + description=stream_dbe.description, turn_id=stream_dbe.turn_id, flags=SessionStreamFlags.model_validate(stream_dbe.flags) if stream_dbe.flags @@ -57,6 +62,10 @@ def map_stream_dto_to_dbe_edit( stream: SessionStreamEdit, ) -> None: stream_dbe.updated_by_id = user_id + if stream.name is not None: + stream_dbe.name = stream.name + if stream.description is not None: + stream_dbe.description = stream.description if stream.flags is not None: stream_dbe.flags = stream.flags.model_dump(mode="json") if stream.tags is not None: @@ -65,3 +74,17 @@ def map_stream_dto_to_dbe_edit( stream_dbe.meta = stream.meta if stream.turn_id is not None: stream_dbe.turn_id = stream.turn_id + + +def map_stream_dto_to_dbe_header_edit( + *, + stream_dbe: SessionStreamDBE, + user_id: Optional[UUID], + header: SessionStreamHeaderEdit, +) -> None: + """The rename edit: only ever touches name/description — never flags/turn_id.""" + stream_dbe.updated_by_id = user_id + if header.name is not None: + stream_dbe.name = header.name + if header.description is not None: + stream_dbe.description = header.description diff --git a/api/oss/src/dbs/postgres/sessions/states/__init__.py b/api/oss/src/dbs/postgres/sessions/turns/__init__.py similarity index 100% rename from api/oss/src/dbs/postgres/sessions/states/__init__.py rename to api/oss/src/dbs/postgres/sessions/turns/__init__.py diff --git a/api/oss/src/dbs/postgres/sessions/turns/dao.py b/api/oss/src/dbs/postgres/sessions/turns/dao.py new file mode 100644 index 0000000000..d5d88c7a9b --- /dev/null +++ b/api/oss/src/dbs/postgres/sessions/turns/dao.py @@ -0,0 +1,290 @@ +from typing import List, Optional +from uuid import UUID + +from sqlalchemy import and_, delete as sa_delete, or_, select +from sqlalchemy import update as sa_update +from sqlalchemy.exc import IntegrityError + +from oss.src.core.sessions.turns.dtos import ( + HarnessKind, + SessionTurn, + SessionTurnComplete, + SessionTurnCreate, + SessionTurnQuery, +) +from oss.src.core.sessions.turns.interfaces import SessionTurnsDAOInterface +from oss.src.core.shared.dtos import Windowing +from oss.src.core.shared.exceptions import EntityCreationConflict +from oss.src.dbs.postgres.sessions.turns.dbes import SessionTurnDBE +from oss.src.dbs.postgres.sessions.turns.mappings import ( + map_turn_dbe_to_dto, + map_turn_dto_to_dbe_create, +) +from oss.src.dbs.postgres.sessions.turns.utils import query_turn_references +from oss.src.dbs.postgres.shared.engine import ( + TransactionsEngine, + get_transactions_engine, +) + + +class SessionTurnsDAO(SessionTurnsDAOInterface): + def __init__(self, engine: TransactionsEngine = None): + if engine is None: + engine = get_transactions_engine() + self.engine = engine + + async def append( + self, + *, + project_id: UUID, + user_id: Optional[UUID], + # + turn: SessionTurnCreate, + ) -> SessionTurn: + dbe = map_turn_dto_to_dbe_create( + project_id=project_id, + user_id=user_id, + turn=turn, + ) + async with self.engine.session() as session: + try: + session.add(dbe) + await session.commit() + await session.refresh(dbe) + except IntegrityError as e: + await session.rollback() + error_str = str(e.orig) if e.orig else str(e) + if "ix_session_turns_project_id_session_id_turn_index" in error_str: + raise EntityCreationConflict( + entity="Session turn", + message=( + f"Session turn {turn.turn_index} already exists for " + f"session {turn.session_id}." + ), + conflict={ + "session_id": turn.session_id, + "turn_index": turn.turn_index, + }, + ) from e + raise + return map_turn_dbe_to_dto(turn_dbe=dbe) + + async def complete( + self, + *, + project_id: UUID, + # + turn: SessionTurnComplete, + ) -> Optional[SessionTurn]: + values = {"end_time": turn.end_time} + if turn.agent_session_id is not None: + values["agent_session_id"] = turn.agent_session_id + + async with self.engine.session() as session: + stmt = ( + sa_update(SessionTurnDBE) + .where( + SessionTurnDBE.project_id == project_id, + SessionTurnDBE.session_id == turn.session_id, + SessionTurnDBE.turn_index == turn.turn_index, + SessionTurnDBE.end_time.is_(None), + ) + .values(**values) + .returning(SessionTurnDBE) + ) + result = await session.execute(stmt) + dbe = result.scalar_one_or_none() + if dbe is not None: + await session.commit() + await session.refresh(dbe) + return map_turn_dbe_to_dto(turn_dbe=dbe) + + stmt = select(SessionTurnDBE).where( + SessionTurnDBE.project_id == project_id, + SessionTurnDBE.session_id == turn.session_id, + SessionTurnDBE.turn_index == turn.turn_index, + ) + result = await session.execute(stmt) + dbe = result.scalar_one_or_none() + + if dbe is None: + return None + return map_turn_dbe_to_dto(turn_dbe=dbe) + + async def fetch_turn( + self, + *, + project_id: UUID, + # + turn_id: UUID, + ) -> Optional[SessionTurn]: + async with self.engine.session() as session: + stmt = select(SessionTurnDBE).where( + SessionTurnDBE.project_id == project_id, + SessionTurnDBE.id == turn_id, + ) + result = await session.execute(stmt) + dbe = result.scalar_one_or_none() + if dbe is None: + return None + return map_turn_dbe_to_dto(turn_dbe=dbe) + + async def query_turns( + self, + *, + project_id: UUID, + # + query: Optional[SessionTurnQuery] = None, + windowing: Optional[Windowing] = None, + ) -> List[SessionTurn]: + async with self.engine.session() as session: + stmt = select(SessionTurnDBE).where( + SessionTurnDBE.project_id == project_id, + ) + + if query is not None: + if query.session_id is not None: + stmt = stmt.where( + SessionTurnDBE.session_id == query.session_id, + ) + if query.stream_id is not None: + stmt = stmt.where( + SessionTurnDBE.stream_id == query.stream_id, + ) + if query.harness_kind is not None: + stmt = stmt.where( + SessionTurnDBE.harness_kind == query.harness_kind.value, + ) + if query.references is not None: + turn_references = query_turn_references(query) + if turn_references is not None: + stmt = stmt.where( + SessionTurnDBE.references.contains(turn_references), + ) + + descending = windowing is None or windowing.order != "ascending" + time_attribute = SessionTurnDBE.start_time + + if windowing: + if windowing.newest is not None: + if descending and windowing.next is None: + stmt = stmt.where(time_attribute < windowing.newest) + else: + stmt = stmt.where(time_attribute <= windowing.newest) + if windowing.oldest is not None: + if not descending and windowing.next is None: + stmt = stmt.where(time_attribute > windowing.oldest) + else: + stmt = stmt.where(time_attribute >= windowing.oldest) + + if windowing.next is not None: + cursor_stmt = select( + SessionTurnDBE.turn_index, + SessionTurnDBE.id, + ).where( + SessionTurnDBE.project_id == project_id, + SessionTurnDBE.id == windowing.next, + ) + cursor_result = await session.execute(cursor_stmt) + cursor = cursor_result.one_or_none() + if cursor is not None: + cursor_index, cursor_id = cursor + if descending: + stmt = stmt.where( + or_( + SessionTurnDBE.turn_index < cursor_index, + and_( + SessionTurnDBE.turn_index == cursor_index, + SessionTurnDBE.id < cursor_id, + ), + ) + ) + else: + stmt = stmt.where( + or_( + SessionTurnDBE.turn_index > cursor_index, + and_( + SessionTurnDBE.turn_index == cursor_index, + SessionTurnDBE.id > cursor_id, + ), + ) + ) + + if descending: + stmt = stmt.order_by( + SessionTurnDBE.turn_index.desc(), + SessionTurnDBE.id.desc(), + ) + else: + stmt = stmt.order_by( + SessionTurnDBE.turn_index.asc(), + SessionTurnDBE.id.asc(), + ) + + if windowing and windowing.limit: + stmt = stmt.limit(windowing.limit) + + result = await session.execute(stmt) + return [map_turn_dbe_to_dto(turn_dbe=dbe) for dbe in result.scalars().all()] + + async def latest_turn( + self, + *, + project_id: UUID, + session_id: str, + ) -> Optional[SessionTurn]: + async with self.engine.session() as session: + stmt = ( + select(SessionTurnDBE) + .where( + SessionTurnDBE.project_id == project_id, + SessionTurnDBE.session_id == session_id, + ) + .order_by(SessionTurnDBE.turn_index.desc()) + .limit(1) + ) + result = await session.execute(stmt) + dbe = result.scalar_one_or_none() + if dbe is None: + return None + return map_turn_dbe_to_dto(turn_dbe=dbe) + + async def latest_turn_per_harness_kind( + self, + *, + project_id: UUID, + session_id: str, + harness_kind: HarnessKind, + ) -> Optional[SessionTurn]: + async with self.engine.session() as session: + stmt = ( + select(SessionTurnDBE) + .where( + SessionTurnDBE.project_id == project_id, + SessionTurnDBE.session_id == session_id, + SessionTurnDBE.harness_kind == harness_kind.value, + ) + .order_by(SessionTurnDBE.turn_index.desc()) + .limit(1) + ) + result = await session.execute(stmt) + dbe = result.scalar_one_or_none() + if dbe is None: + return None + return map_turn_dbe_to_dto(turn_dbe=dbe) + + async def delete_by_session_id( + self, + *, + project_id: UUID, + session_id: str, + ) -> int: + """Hard delete — no soft-delete for turns (session-scoped fan-out, WP5).""" + async with self.engine.session() as session: + stmt = sa_delete(SessionTurnDBE).where( + SessionTurnDBE.project_id == project_id, + SessionTurnDBE.session_id == session_id, + ) + result = await session.execute(stmt) + await session.commit() + return result.rowcount or 0 diff --git a/api/oss/src/dbs/postgres/sessions/turns/dbas.py b/api/oss/src/dbs/postgres/sessions/turns/dbas.py new file mode 100644 index 0000000000..01c4d9b606 --- /dev/null +++ b/api/oss/src/dbs/postgres/sessions/turns/dbas.py @@ -0,0 +1,44 @@ +from sqlalchemy import Column, Integer, String, TIMESTAMP, UUID +from sqlalchemy.dialects.postgresql import JSONB + +from oss.src.dbs.postgres.shared.dbas import ( + IdentifierDBA, + LifecycleDBA, + ProjectScopeDBA, +) + + +class SessionTurnDBA( + ProjectScopeDBA, + LifecycleDBA, + IdentifierDBA, +): + __abstract__ = True + + # Bare string correlator — NOT an FK (sessions may be external). Spine: NOT NULL. + session_id = Column(String, nullable=False) + + # Per-execution correlator; nullable for rows written before producers supplied it. + turn_id = Column(UUID(as_uuid=True), nullable=True) + + # Spine: NOT NULL — every turn is written with stream_id in hand (from the heartbeat). + stream_id = Column(UUID(as_uuid=True), nullable=False) + + turn_index = Column(Integer, nullable=False) + + # Enum-validated at the DTO (HarnessKind); plain varchar column here. + harness_kind = Column(String, nullable=False) + + agent_session_id = Column(String, nullable=True) + sandbox_id = Column(String, nullable=True) + + # eval_runs pattern: list of {id, slug, version} dicts, GIN jsonb_path_ops, .contains(). + references = Column(JSONB(none_as_null=True), nullable=True) + + # Bridge — nullable. trace_id is a 128-bit OTel trace id (fits UUID); span_id is a + # 64-bit OTel span id (16 hex), NOT a UUID — stored as text (see OTelSpanId). + trace_id = Column(UUID(as_uuid=True), nullable=True) + span_id = Column(String, nullable=True) + + start_time = Column(TIMESTAMP(timezone=True), nullable=True) + end_time = Column(TIMESTAMP(timezone=True), nullable=True) diff --git a/api/oss/src/dbs/postgres/sessions/turns/dbes.py b/api/oss/src/dbs/postgres/sessions/turns/dbes.py new file mode 100644 index 0000000000..4209532575 --- /dev/null +++ b/api/oss/src/dbs/postgres/sessions/turns/dbes.py @@ -0,0 +1,50 @@ +from sqlalchemy import ( + ForeignKeyConstraint, + Index, + PrimaryKeyConstraint, +) + +from oss.src.dbs.postgres.shared.base import Base +from oss.src.dbs.postgres.sessions.turns.dbas import SessionTurnDBA + + +class SessionTurnDBE(Base, SessionTurnDBA): + """One row per turn — the transcript twin of a trace. 1:many by session_id. + + No data/flags/tags/meta: every field is first-class/queryable. Resume state + (agent_session_id/sandbox_id/turn_index) is read off the latest row, not folded. + """ + + __tablename__ = "session_turns" + + __table_args__ = ( + ForeignKeyConstraint( + ["project_id"], + ["projects.id"], + ondelete="CASCADE", + ), + ForeignKeyConstraint( + ["project_id", "stream_id"], + ["session_streams.project_id", "session_streams.id"], + ondelete="NO ACTION", + ), + PrimaryKeyConstraint("project_id", "id"), + Index( + "ix_session_turns_project_id_session_id", + "project_id", + "session_id", + ), + Index( + "ix_session_turns_project_id_session_id_turn_index", + "project_id", + "session_id", + "turn_index", + unique=True, + ), + Index( + "ix_session_turns_references", + "references", + postgresql_using="gin", + postgresql_ops={"references": "jsonb_path_ops"}, + ), + ) diff --git a/api/oss/src/dbs/postgres/sessions/turns/mappings.py b/api/oss/src/dbs/postgres/sessions/turns/mappings.py new file mode 100644 index 0000000000..394d75eb67 --- /dev/null +++ b/api/oss/src/dbs/postgres/sessions/turns/mappings.py @@ -0,0 +1,84 @@ +from typing import List, Optional +from uuid import UUID + +from oss.src.core.shared.dtos import Reference +from oss.src.core.sessions.turns.dtos import ( + HarnessKind, + SessionTurn, + SessionTurnCreate, +) +from oss.src.dbs.postgres.sessions.turns.dbes import SessionTurnDBE + + +def _references_to_json( + references: Optional[List[Reference]], +) -> Optional[List[dict]]: + if not references: + return None + return [ + reference.model_dump(mode="json", exclude_none=True) for reference in references + ] + + +def _references_from_json( + references: Optional[List[dict]], +) -> Optional[List[Reference]]: + if not references: + return None + return [Reference.model_validate(reference) for reference in references] + + +def map_turn_dto_to_dbe_create( + *, + project_id: UUID, + user_id: Optional[UUID], + # + turn: SessionTurnCreate, +) -> SessionTurnDBE: + return SessionTurnDBE( + project_id=project_id, + created_by_id=user_id, + # + session_id=turn.session_id, + turn_id=turn.turn_id, + stream_id=turn.stream_id, + turn_index=turn.turn_index, + harness_kind=turn.harness_kind.value, + agent_session_id=turn.agent_session_id, + sandbox_id=turn.sandbox_id, + references=_references_to_json(turn.references), + trace_id=turn.trace_id, + span_id=turn.span_id, + start_time=turn.start_time, + end_time=turn.end_time, + ) + + +def map_turn_dbe_to_dto( + *, + turn_dbe: SessionTurnDBE, +) -> SessionTurn: + return SessionTurn( + id=turn_dbe.id, + # + created_at=turn_dbe.created_at, + updated_at=turn_dbe.updated_at, + deleted_at=turn_dbe.deleted_at, + created_by_id=turn_dbe.created_by_id, + updated_by_id=turn_dbe.updated_by_id, + deleted_by_id=turn_dbe.deleted_by_id, + # + project_id=turn_dbe.project_id, + session_id=turn_dbe.session_id, + turn_id=turn_dbe.turn_id, + stream_id=turn_dbe.stream_id, + turn_index=turn_dbe.turn_index, + harness_kind=HarnessKind(turn_dbe.harness_kind), + agent_session_id=turn_dbe.agent_session_id, + sandbox_id=turn_dbe.sandbox_id, + references=_references_from_json(turn_dbe.references), + trace_id=turn_dbe.trace_id, + span_id=turn_dbe.span_id, + start_time=turn_dbe.start_time, + end_time=turn_dbe.end_time, + ) diff --git a/api/oss/src/dbs/postgres/sessions/turns/utils.py b/api/oss/src/dbs/postgres/sessions/turns/utils.py new file mode 100644 index 0000000000..578dd04fbb --- /dev/null +++ b/api/oss/src/dbs/postgres/sessions/turns/utils.py @@ -0,0 +1,22 @@ +from typing import Any, Dict, List, Optional + +from oss.src.core.sessions.turns.dtos import SessionTurnQuery + + +def query_turn_references( + turn: Optional[SessionTurnQuery] = None, +) -> Optional[List[Any]]: + """eval_runs pattern: flatten to bare {id, slug, version} dicts for .contains().""" + if not turn or not turn.references: + return None + + _references: Dict[Any, Any] = dict() + + for reference in turn.references: + _key = reference.id or reference.slug + _references[_key] = reference.model_dump( + mode="json", + exclude_none=True, + ) + + return list(_references.values()) or None diff --git a/api/oss/src/dbs/postgres/tracing/dao.py b/api/oss/src/dbs/postgres/tracing/dao.py index ee0a11c3c9..52649e7f67 100644 --- a/api/oss/src/dbs/postgres/tracing/dao.py +++ b/api/oss/src/dbs/postgres/tracing/dao.py @@ -889,6 +889,9 @@ async def _query_by_group( # BASE QUERY: Use DISTINCT ON pattern (like query() does for traces) # DISTINCT ON picks one row per identifier based on ORDER BY + # TODO(WP0.7, deferred): SpanDBE.session_id/user_id are now indexed + # root-only columns (see migration oss000000003) — this JSONB path + # scan can become a column scan. Left as-is; not blocking. id_column = SpanDBE.attributes["ag"][group]["id"].as_string() base = ( select( diff --git a/api/oss/src/dbs/postgres/tracing/dbas.py b/api/oss/src/dbs/postgres/tracing/dbas.py index 99ae1d4844..2d90cf84bb 100644 --- a/api/oss/src/dbs/postgres/tracing/dbas.py +++ b/api/oss/src/dbs/postgres/tracing/dbas.py @@ -58,6 +58,21 @@ class SpanDBA: nullable=True, ) + # Root-span-only (parent_id IS NULL); promoted from ag.session/user/agent + # attributes at ingest_span_dtos. Nullable — children never populate these. + session_id = Column( + VARCHAR, + nullable=True, + ) + user_id = Column( + VARCHAR, + nullable=True, + ) + agent_id = Column( + VARCHAR, + nullable=True, + ) + attributes = Column( JSONB(none_as_null=True), nullable=True, diff --git a/api/oss/src/dbs/postgres/tracing/dbes.py b/api/oss/src/dbs/postgres/tracing/dbes.py index af265a8c5a..a2bcf581d2 100644 --- a/api/oss/src/dbs/postgres/tracing/dbes.py +++ b/api/oss/src/dbs/postgres/tracing/dbes.py @@ -40,6 +40,11 @@ class SpanDBE( "project_id", "start_time", ), # for sorting and scrolling + Index( + "ix_spans_project_id_session_id", + "project_id", + "session_id", + ), # for session filtering (root-only column) Index( "ix_spans_project_id_trace_type", "project_id", diff --git a/api/oss/src/dbs/postgres/tracing/mappings.py b/api/oss/src/dbs/postgres/tracing/mappings.py index 6a1117cbc3..101e75ca8f 100644 --- a/api/oss/src/dbs/postgres/tracing/mappings.py +++ b/api/oss/src/dbs/postgres/tracing/mappings.py @@ -56,6 +56,9 @@ def map_span_dbe_to_span_dbe( existing_span_dbe.end_time = new_span_dbe.end_time existing_span_dbe.status_code = new_span_dbe.status_code existing_span_dbe.status_message = new_span_dbe.status_message + existing_span_dbe.session_id = new_span_dbe.session_id + existing_span_dbe.user_id = new_span_dbe.user_id + existing_span_dbe.agent_id = new_span_dbe.agent_id existing_span_dbe.attributes = new_span_dbe.attributes existing_span_dbe.events = new_span_dbe.events existing_span_dbe.links = new_span_dbe.links @@ -119,6 +122,10 @@ def map_span_dbe_to_span_dto( status_code=OTelStatusCode(span_dbe.status_code), status_message=span_dbe.status_message, # + session_id=span_dbe.session_id, + user_id=span_dbe.user_id, + agent_id=span_dbe.agent_id, + # attributes=span_dbe.attributes, # references=references if references else None, @@ -162,6 +169,10 @@ def map_span_dto_to_span_dbe( status_code=span_dto.status_code, status_message=span_dto.status_message, # + session_id=span_dto.session_id, + user_id=span_dto.user_id, + agent_id=span_dto.agent_id, + # attributes=span_dto.attributes, # references=( diff --git a/api/oss/src/dbs/postgres/tracing/utils.py b/api/oss/src/dbs/postgres/tracing/utils.py index dbd2cda916..70c8815453 100644 --- a/api/oss/src/dbs/postgres/tracing/utils.py +++ b/api/oss/src/dbs/postgres/tracing/utils.py @@ -787,6 +787,12 @@ def filter( # pylint:disable=redefined-builtin clauses.extend(_handle_uuid_field(condition)) elif field == Fields.CONTENT: clauses.extend(_handle_fts_field(condition)) + elif field == Fields.SESSION_ID: + clauses.extend(_handle_string_field(condition)) + elif field == Fields.USER_ID: + clauses.extend(_handle_string_field(condition)) + elif field == Fields.AGENT_ID: + clauses.extend(_handle_string_field(condition)) else: # raise FilteringException( # f"Unsupported condition field: {field}", diff --git a/api/oss/src/utils/env.py b/api/oss/src/utils/env.py index 39409edbed..3a8aca3320 100644 --- a/api/oss/src/utils/env.py +++ b/api/oss/src/utils/env.py @@ -1047,6 +1047,14 @@ class RunnerConfig(BaseModel): default_factory=_default_sandbox_provider_default ) + # Direct API -> runner hop for `kill` (W7.3): the same base URL and shared-secret token + # the Python agent service (services/oss/src/agent/config.py's `runner_url()`) already + # uses to reach the runner's HTTP surface. None/blank means kill only edits the Redis + # nest + row (best-effort; the runner's own orphan sweep/TTL expiry still reclaims the + # sandbox eventually, just not immediately). + internal_url: Optional[str] = os.getenv("AGENTA_RUNNER_INTERNAL_URL") or None + token: Optional[str] = os.getenv("AGENTA_RUNNER_TOKEN") or None + model_config = ConfigDict(extra="ignore") @model_validator(mode="after") diff --git a/api/oss/tests/pytest/acceptance/session_states/test_harness_sessions_roundtrip.py b/api/oss/tests/pytest/acceptance/session_states/test_harness_sessions_roundtrip.py deleted file mode 100644 index ba2b54d6af..0000000000 --- a/api/oss/tests/pytest/acceptance/session_states/test_harness_sessions_roundtrip.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Acceptance tests for the S2/S2b/S4 durable continuity state on /sessions/states/ and -/sessions/mounts/sign (OSS edition). Follows test_session_states_basics.py's setup exactly. - -The continuity fields (latest_agent_session_id / harness_sessions / latest_turn_index) live -inside the existing `data` JSON column, so NO migration is required -- `session_states.data` -already exists. - -Requires a live stack (AGENTA_API_URL/AGENTA_AUTH_KEY) -- see the pytest.ini `acceptance` -marker. -""" - -import uuid - - -class TestHarnessSessionsRoundtrip: - """GET/PUT /sessions/states/ round-trips data.{latest_agent_session_id, - harness_sessions, latest_turn_index}; POST /sessions/mounts/sign accepts - name=claude-projects.""" - - def test_put_get_roundtrips_harness_sessions(self, authed_api): - session_id = str(uuid.uuid4()) - data = { - "latest_agent_session_id": "agent-claude-2", - "harness_sessions": { - "claude": { - "agent_session_id": "agent-claude-2", - "turn_index": 2, - }, - "pi": { - "agent_session_id": "agent-pi-1", - "turn_index": 1, - }, - }, - "latest_turn_index": 2, - } - - put_response = authed_api( - "PUT", - "/sessions/states/", - params={"session_id": session_id}, - json={"data": data}, - ) - assert put_response.status_code == 200 - put_state = put_response.json()["session_state"] - assert put_state["data"]["latest_agent_session_id"] == "agent-claude-2" - assert put_state["data"]["latest_turn_index"] == 2 - assert put_state["data"]["harness_sessions"] == data["harness_sessions"] - - get_response = authed_api( - "GET", "/sessions/states/", params={"session_id": session_id} - ) - assert get_response.status_code == 200 - get_state = get_response.json()["session_state"] - assert get_state["data"]["latest_agent_session_id"] == "agent-claude-2" - assert get_state["data"]["latest_turn_index"] == 2 - assert get_state["data"]["harness_sessions"] == data["harness_sessions"] - - def test_harness_sessions_survive_a_read_modify_write_merge(self, authed_api): - # Mirrors the runner's syncHarnessSessionDurable: GET, splice one harness's entry, - # PUT the whole data back -- the other harness's entry must survive untouched. - session_id = str(uuid.uuid4()) - authed_api( - "PUT", - "/sessions/states/", - params={"session_id": session_id}, - json={ - "data": { - "harness_sessions": { - "pi": { - "agent_session_id": "agent-pi-1", - "turn_index": 1, - }, - }, - "latest_turn_index": 1, - }, - }, - ) - - merged = authed_api( - "PUT", - "/sessions/states/", - params={"session_id": session_id}, - json={ - "data": { - "latest_agent_session_id": "agent-claude-2", - "harness_sessions": { - "pi": { - "agent_session_id": "agent-pi-1", - "turn_index": 1, - }, - "claude": { - "agent_session_id": "agent-claude-2", - "turn_index": 2, - }, - }, - "latest_turn_index": 2, - }, - }, - ) - assert merged.status_code == 200 - state = merged.json()["session_state"] - assert state["data"]["harness_sessions"]["pi"]["turn_index"] == 1 - assert state["data"]["harness_sessions"]["claude"]["turn_index"] == 2 - - def test_mounts_sign_accepts_claude_projects_name(self, authed_api): - session_id = str(uuid.uuid4()) - response = authed_api( - "POST", - "/sessions/mounts/sign", - params={"session_id": session_id, "name": "claude-projects"}, - ) - # 200 with usable credentials, or 503 if the store is not configured in this - # environment -- either is a legitimate outcome; the endpoint must not 4xx/500 on a - # valid `name` value. - assert response.status_code in (200, 503) - if response.status_code == 200: - body = response.json() - assert body["mount"]["name"] == "claude-projects" diff --git a/api/oss/tests/pytest/acceptance/session_states/test_session_states_basics.py b/api/oss/tests/pytest/acceptance/session_states/test_session_states_basics.py deleted file mode 100644 index ee05ffdc0e..0000000000 --- a/api/oss/tests/pytest/acceptance/session_states/test_session_states_basics.py +++ /dev/null @@ -1,299 +0,0 @@ -"""Acceptance tests for /sessions/states/ endpoints (OSS edition).""" - -import uuid - - -class TestSessionStatesBasics: - """GET / PUT / POST /sessions/states/?session_id=... — happy paths.""" - - def test_get_missing_state_returns_empty(self, authed_api): - session_id = str(uuid.uuid4()) - response = authed_api( - "GET", "/sessions/states/", params={"session_id": session_id} - ) - assert response.status_code == 200 - body = response.json() - assert body["count"] == 0 - assert body.get("session_state") is None - - def test_put_creates_state(self, authed_api): - session_id = str(uuid.uuid4()) - payload = { - "data": {"latest_agent_session_id": "agent-abc"}, - "sandbox_id": "sandbox-001", - } - - response = authed_api( - "PUT", - "/sessions/states/", - params={"session_id": session_id}, - json=payload, - ) - assert response.status_code == 200 - body = response.json() - assert body["count"] == 1 - state = body["session_state"] - assert state["session_id"] == session_id - assert state["data"]["latest_agent_session_id"] == "agent-abc" - assert state["sandbox_id"] == "sandbox-001" - - def test_get_returns_persisted_state(self, authed_api): - session_id = str(uuid.uuid4()) - payload = { - "data": {"latest_agent_session_id": "agent-abc"}, - "sandbox_id": "sbx-999", - } - - authed_api( - "PUT", - "/sessions/states/", - params={"session_id": session_id}, - json=payload, - ) - - response = authed_api( - "GET", "/sessions/states/", params={"session_id": session_id} - ) - assert response.status_code == 200 - body = response.json() - assert body["count"] == 1 - assert body["session_state"]["session_id"] == session_id - assert body["session_state"]["sandbox_id"] == "sbx-999" - - def test_put_upserts_on_second_call(self, authed_api): - session_id = str(uuid.uuid4()) - - authed_api( - "PUT", - "/sessions/states/", - params={"session_id": session_id}, - json={"data": {"latest_turn_index": 1}}, - ) - - response = authed_api( - "PUT", - "/sessions/states/", - params={"session_id": session_id}, - json={"data": {"latest_turn_index": 2}, "sandbox_id": "sbx-v2"}, - ) - assert response.status_code == 200 - state = response.json()["session_state"] - assert state["data"]["latest_turn_index"] == 2 - assert state["sandbox_id"] == "sbx-v2" - - # GET should reflect the latest upsert - get_resp = authed_api( - "GET", "/sessions/states/", params={"session_id": session_id} - ) - assert get_resp.json()["session_state"]["data"]["latest_turn_index"] == 2 - - def test_set_sandbox_id_endpoint(self, authed_api): - session_id = str(uuid.uuid4()) - - # create the state first - authed_api( - "PUT", - "/sessions/states/", - params={"session_id": session_id}, - json={"data": {"latest_agent_session_id": "agent-abc"}}, - ) - - # update sandbox_id independently - response = authed_api( - "PUT", - "/sessions/states/", - params={"session_id": session_id}, - json={"sandbox_id": "sbx-new"}, - ) - assert response.status_code == 200 - state = response.json()["session_state"] - assert state["sandbox_id"] == "sbx-new" - assert state["data"] == {"latest_agent_session_id": "agent-abc"} - - def test_set_sandbox_id_clear(self, authed_api): - session_id = str(uuid.uuid4()) - - authed_api( - "PUT", - "/sessions/states/", - params={"session_id": session_id}, - json={"data": {}, "sandbox_id": "sbx-to-clear"}, - ) - - response = authed_api( - "PUT", - "/sessions/states/", - params={"session_id": session_id}, - json={"sandbox_id": None}, - ) - assert response.status_code == 200 - state = response.json()["session_state"] - assert state.get("sandbox_id") is None - - def test_invalid_session_id_rejected(self, authed_api): - # slashes are not allowed - response = authed_api( - "GET", "/sessions/states/", params={"session_id": "foo/bar"} - ) - assert response.status_code == 400 - - def test_invalid_session_id_chars_rejected(self, authed_api): - # spaces are not allowed - response = authed_api( - "GET", "/sessions/states/", params={"session_id": "foo bar"} - ) - assert response.status_code == 400 - - def test_sandbox_id_on_missing_state_creates_state(self, authed_api): - session_id = str(uuid.uuid4()) - response = authed_api( - "PUT", - "/sessions/states/", - params={"session_id": session_id}, - json={"sandbox_id": "sbx-orphan"}, - ) - assert response.status_code == 200 - body = response.json() - assert body["count"] == 1 - state = body["session_state"] - assert state["session_id"] == session_id - assert state["sandbox_id"] == "sbx-orphan" - assert state.get("data") is None - - def test_sandbox_id_update_preserves_existing_data(self, authed_api): - session_id = str(uuid.uuid4()) - authed_api( - "PUT", - "/sessions/states/", - params={"session_id": session_id}, - json={ - "data": { - "latest_turn_index": 1, - "latest_agent_session_id": "agent-abc", - } - }, - ) - - response = authed_api( - "PUT", - "/sessions/states/", - params={"session_id": session_id}, - json={"sandbox_id": "sbx-preserved"}, - ) - - assert response.status_code == 200 - state = response.json()["session_state"] - assert state["sandbox_id"] == "sbx-preserved" - assert state["data"] == { - "latest_turn_index": 1, - "latest_agent_session_id": "agent-abc", - } - - def test_sandbox_id_accepts_runner_post(self, authed_api): - session_id = str(uuid.uuid4()) - response = authed_api( - "POST", - "/sessions/states/", - params={"session_id": session_id}, - json={"sandbox_id": "sbx-runner"}, - ) - - assert response.status_code == 200 - state = response.json()["session_state"] - assert state["session_id"] == session_id - assert state["sandbox_id"] == "sbx-runner" - - def test_guarded_pointer_write_applies_at_latest_turn(self, authed_api): - session_id = str(uuid.uuid4()) - authed_api( - "PUT", - "/sessions/states/", - params={"session_id": session_id}, - json={ - "data": {"latest_turn_index": 2}, - "sandbox_id": "sbx-old", - }, - ) - - response = authed_api( - "PUT", - "/sessions/states/", - params={"session_id": session_id}, - json={ - "sandbox_id": "sbx-new", - "sandbox_turn_index": 2, - }, - ) - - assert response.status_code == 200 - state = response.json()["session_state"] - assert state["sandbox_id"] == "sbx-new" - - def test_stale_guarded_pointer_write_returns_unchanged_row(self, authed_api): - session_id = str(uuid.uuid4()) - authed_api( - "PUT", - "/sessions/states/", - params={"session_id": session_id}, - json={ - "data": {"latest_turn_index": 3}, - "sandbox_id": "sbx-current", - }, - ) - - response = authed_api( - "PUT", - "/sessions/states/", - params={"session_id": session_id}, - json={ - "sandbox_id": "sbx-stale", - "sandbox_turn_index": 2, - }, - ) - - assert response.status_code == 200 - state = response.json()["session_state"] - assert state["sandbox_id"] == "sbx-current" - assert state["data"]["latest_turn_index"] == 3 - - def test_tokenless_pointer_write_remains_unconditional(self, authed_api): - session_id = str(uuid.uuid4()) - authed_api( - "PUT", - "/sessions/states/", - params={"session_id": session_id}, - json={ - "data": {"latest_turn_index": 4}, - "sandbox_id": "sbx-old", - }, - ) - - response = authed_api( - "PUT", - "/sessions/states/", - params={"session_id": session_id}, - json={ - "sandbox_id": "sbx-tokenless", - }, - ) - - assert response.status_code == 200 - state = response.json()["session_state"] - assert state["sandbox_id"] == "sbx-tokenless" - - def test_guarded_pointer_write_creates_missing_row(self, authed_api): - session_id = str(uuid.uuid4()) - response = authed_api( - "PUT", - "/sessions/states/", - params={"session_id": session_id}, - json={ - "sandbox_id": "sbx-first", - "sandbox_turn_index": 7, - }, - ) - - assert response.status_code == 200 - state = response.json()["session_state"] - assert state["sandbox_id"] == "sbx-first" - assert state.get("data") is None diff --git a/api/oss/tests/pytest/acceptance/session_states/__init__.py b/api/oss/tests/pytest/acceptance/sessions/__init__.py similarity index 100% rename from api/oss/tests/pytest/acceptance/session_states/__init__.py rename to api/oss/tests/pytest/acceptance/sessions/__init__.py diff --git a/api/oss/tests/pytest/acceptance/sessions/test_archive_unarchive.py b/api/oss/tests/pytest/acceptance/sessions/test_archive_unarchive.py new file mode 100644 index 0000000000..b0b8300779 --- /dev/null +++ b/api/oss/tests/pytest/acceptance/sessions/test_archive_unarchive.py @@ -0,0 +1,54 @@ +"""Acceptance test for the session archive/unarchive lifecycle routes (OSS edition). + +POST /sessions/archive and POST /sessions/unarchive (session_id as a query param) soft-archive +and restore the merged stream row; /sessions/query must exclude an archived session and return +it again once unarchived. Covers TEST-GAPS.md §"archive / unarchive lifecycle", which had only +service-level coverage and no route test. + +Requires a live stack (AGENTA_API_URL/AGENTA_AUTH_KEY) — see the pytest `acceptance` marker. +""" + +import uuid + + +def _session_ids(query_response) -> set: + return {s["session_id"] for s in query_response.json().get("sessions", [])} + + +class TestSessionArchiveUnarchive: + """A session created via the stream header is queryable, drops out of /sessions/query when + archived, and returns when unarchived.""" + + def test_archive_excludes_then_unarchive_restores(self, authed_api): + session_id = str(uuid.uuid4()) + + # Create the merged stream row (this is what /sessions/query lists). + create = authed_api( + "PUT", + "/sessions/streams/header", + params={"session_id": session_id}, + json={"name": "Archive Me", "description": "lifecycle check"}, + ) + assert create.status_code == 200, create.text + + listed = authed_api("POST", "/sessions/query", json={}) + assert listed.status_code == 200, listed.text + assert session_id in _session_ids(listed) + + archived = authed_api( + "POST", "/sessions/archive", params={"session_id": session_id} + ) + assert archived.status_code == 200, archived.text + + after_archive = authed_api("POST", "/sessions/query", json={}) + assert after_archive.status_code == 200, after_archive.text + assert session_id not in _session_ids(after_archive) + + unarchived = authed_api( + "POST", "/sessions/unarchive", params={"session_id": session_id} + ) + assert unarchived.status_code == 200, unarchived.text + + after_unarchive = authed_api("POST", "/sessions/query", json={}) + assert after_unarchive.status_code == 200, after_unarchive.text + assert session_id in _session_ids(after_unarchive) diff --git a/api/oss/tests/pytest/acceptance/sessions/test_records_ingest_contract.py b/api/oss/tests/pytest/acceptance/sessions/test_records_ingest_contract.py new file mode 100644 index 0000000000..603ecdd099 --- /dev/null +++ b/api/oss/tests/pytest/acceptance/sessions/test_records_ingest_contract.py @@ -0,0 +1,75 @@ +"""Acceptance test for the record-ingest contract on POST /sessions/records/ingest. + +Pins the regression from debug/qa-sessions-rebase/FINDING-record-ingest-422.md: the runner +posts a 16-hex OTel span_id, and the API had typed span_id as UUID (32 hex) — so every +ingest 422'd and session records silently dropped. This drives the endpoint with the body the +runner actually sends and asserts it is accepted (200) and lands as a queryable record whose +span_id round-trips. + +Requires a live stack (AGENTA_API_URL/AGENTA_AUTH_KEY) with the record worker running — see +the pytest `acceptance` marker. +""" + +import time +import uuid + + +class TestRecordIngestContract: + """POST /sessions/records/ingest accepts the runner-shaped body (16-hex span_id) and the + record becomes queryable with the span id intact.""" + + def test_runner_shaped_ingest_is_accepted_and_persists(self, authed_api): + session_id = str(uuid.uuid4()) + # Exactly the shape services/runner/src/sessions/persist.ts posts: a 16-hex OTel span + # id (NOT a UUID), a turn_id, a per-turn record_index, and the event as attributes. + span_id = uuid.uuid4().hex[:16] + turn_id = f"turn-{uuid.uuid4().hex[:8]}" + + ingest = authed_api( + "POST", + "/sessions/records/ingest", + json={ + "session_id": session_id, + "record_index": 0, + "timestamp": "2026-07-18T00:00:00.000Z", + "record_source": "agent", + "record_type": "message", + "attributes": {"type": "message", "text": "hello from the runner"}, + "turn_id": turn_id, + "span_id": span_id, + }, + ) + assert ingest.status_code == 200, ingest.text + assert ingest.json() == {"ok": True} + + # The record flows through Redis -> worker -> DB; poll the query endpoint until it lands. + record = None + deadline = time.time() + 20 + while time.time() < deadline: + query = authed_api( + "POST", "/sessions/records/query", json={"session_id": session_id} + ) + assert query.status_code == 200, query.text + records = query.json().get("records", []) + if records: + record = records[0] + break + time.sleep(1) + + assert record is not None, "ingested record never became queryable" + assert record["turn_id"] == turn_id + assert record["span_id"] == span_id + + def test_a_uuid_span_id_is_rejected(self, authed_api): + """A 32-hex UUID is not a span id; the endpoint must reject it (422), which is exactly + the validation that a 16-hex span id must pass.""" + response = authed_api( + "POST", + "/sessions/records/ingest", + json={ + "session_id": str(uuid.uuid4()), + "record_source": "agent", + "span_id": uuid.uuid4().hex, # 32 hex — a UUID, not a span id + }, + ) + assert response.status_code == 422, response.text diff --git a/api/oss/tests/pytest/acceptance/sessions/test_stream_header_basics.py b/api/oss/tests/pytest/acceptance/sessions/test_stream_header_basics.py new file mode 100644 index 0000000000..ce1edf4763 --- /dev/null +++ b/api/oss/tests/pytest/acceptance/sessions/test_stream_header_basics.py @@ -0,0 +1,106 @@ +"""Acceptance tests for the session header surface on /sessions/streams/ (OSS edition). + +GET /sessions/streams/ reads the merged stream row {name, description, flags, ...}; +PUT/POST /sessions/streams/header is the rename edit, a full-PUT of {name, description}. +""" + +import uuid + + +class TestSessionStreamHeaderBasics: + """GET /sessions/streams/ + PUT/POST /sessions/streams/header — happy paths.""" + + def test_get_missing_stream_returns_empty(self, authed_api): + session_id = str(uuid.uuid4()) + response = authed_api( + "GET", "/sessions/streams/", params={"session_id": session_id} + ) + assert response.status_code == 200 + body = response.json() + assert body.get("stream") is None + + def test_put_renames_and_creates_row(self, authed_api): + session_id = str(uuid.uuid4()) + payload = {"name": "My Session", "description": "A test session."} + + response = authed_api( + "PUT", + "/sessions/streams/header", + params={"session_id": session_id}, + json=payload, + ) + assert response.status_code == 200 + stream = response.json()["stream"] + assert stream["session_id"] == session_id + assert stream["name"] == "My Session" + assert stream["description"] == "A test session." + + def test_get_returns_persisted_rename(self, authed_api): + session_id = str(uuid.uuid4()) + authed_api( + "PUT", + "/sessions/streams/header", + params={"session_id": session_id}, + json={"name": "Persisted Name"}, + ) + + response = authed_api( + "GET", "/sessions/streams/", params={"session_id": session_id} + ) + assert response.status_code == 200 + stream = response.json()["stream"] + assert stream["session_id"] == session_id + assert stream["name"] == "Persisted Name" + + def test_put_upserts_on_second_call(self, authed_api): + session_id = str(uuid.uuid4()) + + authed_api( + "PUT", + "/sessions/streams/header", + params={"session_id": session_id}, + json={"name": "First"}, + ) + + response = authed_api( + "PUT", + "/sessions/streams/header", + params={"session_id": session_id}, + json={"name": "Second", "description": "updated"}, + ) + assert response.status_code == 200 + stream = response.json()["stream"] + assert stream["name"] == "Second" + assert stream["description"] == "updated" + + get_resp = authed_api( + "GET", "/sessions/streams/", params={"session_id": session_id} + ) + assert get_resp.json()["stream"]["name"] == "Second" + + def test_post_also_accepts_the_rename_edit(self, authed_api): + session_id = str(uuid.uuid4()) + response = authed_api( + "POST", + "/sessions/streams/header", + params={"session_id": session_id}, + json={"name": "Via POST"}, + ) + assert response.status_code == 200 + stream = response.json()["stream"] + assert stream["session_id"] == session_id + assert stream["name"] == "Via POST" + + def test_invalid_session_id_rejected(self, authed_api): + # slashes are not allowed (SessionIdInvalid -> 422 via the domain exception handler) + response = authed_api( + "GET", "/sessions/streams/", params={"session_id": "foo/bar"} + ) + assert response.status_code == 422 + + def test_invalid_session_id_chars_rejected(self, authed_api): + # spaces are not allowed (SessionIdInvalid -> 422 via the domain exception handler) + response = authed_api( + "GET", "/sessions/streams/", params={"session_id": "foo bar"} + ) + assert response.status_code == 422 diff --git a/api/oss/tests/pytest/acceptance/sessions/test_stream_header_roundtrip.py b/api/oss/tests/pytest/acceptance/sessions/test_stream_header_roundtrip.py new file mode 100644 index 0000000000..cdf5a4e00e --- /dev/null +++ b/api/oss/tests/pytest/acceptance/sessions/test_stream_header_roundtrip.py @@ -0,0 +1,75 @@ +"""Acceptance tests for the S1/S8 stream header edit on /sessions/streams/header +and /sessions/mounts/sign (OSS edition). + +PUT /sessions/streams/header is the rename edit -- a full-PUT of {name, description} +-- and GET /sessions/streams/ reads it back. + +Requires a live stack (AGENTA_API_URL/AGENTA_AUTH_KEY) -- see the pytest.ini +`acceptance` marker. +""" + +import uuid + + +class TestSessionStreamHeaderRoundtrip: + """GET /sessions/streams/ + PUT /sessions/streams/header round-trip {name, + description}; POST /sessions/mounts/sign accepts name=claude-projects.""" + + def test_rename_persists_and_round_trips_via_get(self, authed_api): + session_id = str(uuid.uuid4()) + + put_response = authed_api( + "PUT", + "/sessions/streams/header", + params={"session_id": session_id}, + json={"name": "Renamed Session", "description": "roundtrip check"}, + ) + assert put_response.status_code == 200 + put_stream = put_response.json()["stream"] + assert put_stream["name"] == "Renamed Session" + assert put_stream["description"] == "roundtrip check" + + get_response = authed_api( + "GET", "/sessions/streams/", params={"session_id": session_id} + ) + assert get_response.status_code == 200 + get_stream = get_response.json()["stream"] + assert get_stream["name"] == "Renamed Session" + assert get_stream["description"] == "roundtrip check" + + def test_rename_is_a_full_put_partial_fields_preserved(self, authed_api): + # A second PUT that sends only `name` must not clear `description` -- + # exclude_unset means an omitted field is untouched, not nulled. + session_id = str(uuid.uuid4()) + authed_api( + "PUT", + "/sessions/streams/header", + params={"session_id": session_id}, + json={"name": "First", "description": "keep me"}, + ) + + response = authed_api( + "PUT", + "/sessions/streams/header", + params={"session_id": session_id}, + json={"name": "Second"}, + ) + assert response.status_code == 200 + stream = response.json()["stream"] + assert stream["name"] == "Second" + assert stream["description"] == "keep me" + + def test_mounts_sign_accepts_claude_projects_name(self, authed_api): + session_id = str(uuid.uuid4()) + response = authed_api( + "POST", + "/sessions/mounts/sign", + params={"session_id": session_id, "name": "claude-projects"}, + ) + # 200 with usable credentials, or 503 if the store is not configured in this + # environment -- either is a legitimate outcome; the endpoint must not 4xx/500 on a + # valid `name` value. + assert response.status_code in (200, 503) + if response.status_code == 200: + body = response.json() + assert body["mount"]["name"] == "claude-projects" diff --git a/api/oss/tests/pytest/unit/mounts/test_agent_id_backfill.py b/api/oss/tests/pytest/unit/mounts/test_agent_id_backfill.py new file mode 100644 index 0000000000..e5c987a1cf --- /dev/null +++ b/api/oss/tests/pytest/unit/mounts/test_agent_id_backfill.py @@ -0,0 +1,59 @@ +"""Backfill parser for the `agent_id` migration (oss000000014_add_mounts_agent_id). + +The migration's backfill is inline SQL (`split_part(substr(slug, N), '__', 1)`), +matching the sibling core_oss data migrations (oss000000010, oss000000011), +which are also plain SQL with no dedicated unit test. This file pins the +parsing invariant in Python so any drift in the slug format (verified against +`mint_agent_slug`, `core/mounts/service.py`) is caught without a live DB: the +SQL and `mint_agent_slug`/`mint_agent_id` must derive the identical id from +the identical slug. The SQL itself was validated against a real Postgres +instance (ephemeral, local) during implementation. +""" + +from uuid import uuid4 + +from oss.src.core.mounts.service import ( + mint_agent_id, + mint_agent_slug, + mint_session_slug, +) + +_AGENT_SLUG_PREFIX = "__ag__agent__" + + +def _parse_agent_id_like_the_migration_sql(slug: str): + """Python mirror of the migration's `split_part(substr(slug, N), '__', 1)`.""" + if not slug.startswith(_AGENT_SLUG_PREFIX): + return None + rest = slug[len(_AGENT_SLUG_PREFIX) :] + return rest.split("__", 1)[0] + + +def test_parser_recovers_canonical_artifact_id_from_agent_slug(): + artifact_id = "A0B1C2D3-E4F5-4678-9ABC-DEF012345678" + slug = mint_agent_slug(artifact_id=artifact_id, name="My Files") + + parsed = _parse_agent_id_like_the_migration_sql(slug) + + assert parsed == mint_agent_id(artifact_id=artifact_id) + assert parsed == "a0b1c2d3-e4f5-4678-9abc-def012345678" + + +def test_parser_recovers_id_regardless_of_mount_name_length(): + artifact_id = str(uuid4()) + for name in ("default", "notes", "a-much-longer-mount-name-here"): + slug = mint_agent_slug(artifact_id=artifact_id, name=name) + assert _parse_agent_id_like_the_migration_sql(slug) == artifact_id + + +def test_parser_returns_none_for_session_mount_slug(): + """Session-mount rows must stay agent_id-null: the session segment is a + uuid5 hash, not the raw id, and the prefix itself does not match.""" + slug = mint_session_slug(session_id="sess-1", name="cwd") + + assert _parse_agent_id_like_the_migration_sql(slug) is None + + +def test_parser_returns_none_for_unreserved_slug(): + assert _parse_agent_id_like_the_migration_sql("datasets") is None + assert _parse_agent_id_like_the_migration_sql("__ag__unrelated__x") is None diff --git a/api/oss/tests/pytest/unit/mounts/test_agent_mounts.py b/api/oss/tests/pytest/unit/mounts/test_agent_mounts.py index 2a10041d64..dec278f996 100644 --- a/api/oss/tests/pytest/unit/mounts/test_agent_mounts.py +++ b/api/oss/tests/pytest/unit/mounts/test_agent_mounts.py @@ -3,8 +3,8 @@ import pytest -from oss.src.core.mounts.dtos import Mount, MountCreate -from oss.src.core.mounts.service import MountsService, mint_agent_slug +from oss.src.core.mounts.dtos import Mount, MountCreate, MountQuery +from oss.src.core.mounts.service import MountsService, mint_agent_id, mint_agent_slug from oss.src.core.mounts.types import ( MountArtifactIdInvalid, MountArtifactNotFound, @@ -47,6 +47,23 @@ async def archive_mount(self, *, project_id, user_id, mount_id): return archived return None + async def query_mounts(self, *, project_id, mount_query=None, windowing=None): + results = [ + mount + for (mount_project_id, _), mount in self.mounts.items() + if mount_project_id == project_id + ] + if mount_query: + if not mount_query.include_archived: + results = [m for m in results if m.deleted_at is None] + if mount_query.session_id is not None: + results = [m for m in results if m.session_id == mount_query.session_id] + if mount_query.agent_id is not None: + results = [m for m in results if m.agent_id == mount_query.agent_id] + else: + results = [m for m in results if m.deleted_at is None] + return results + @staticmethod def _mount(project_id, mount_create): return Mount( @@ -55,6 +72,7 @@ def _mount(project_id, mount_create): slug=mount_create.slug, name=mount_create.name, session_id=mount_create.session_id, + agent_id=mount_create.agent_id, ) @@ -90,6 +108,7 @@ async def test_agent_mount_upsert_is_idempotent(mount_context): ) assert first.id == second.id assert first.session_id is None + assert first.agent_id == mint_agent_id(artifact_id=artifact_id) assert dao.upsert_calls == 2 assert len(dao.mounts) == 1 @@ -254,3 +273,62 @@ async def test_sign_accepts_static_catalog_artifact_without_db_lookup(): assert mount is not None assert workflows.fetch_calls == 0 + + +# --- agent_id column (session_id/agent_id symmetry, WP6) -------------------- # + + +@pytest.mark.asyncio +async def test_new_agent_mount_has_agent_id_matching_slug_segment(mount_context): + """The populate site (get_or_create_agent_mount): agent_id is the same + canonical id minted into the slug's `__ag__agent____...` segment.""" + service, _, project_id, user_id = mount_context + artifact_id = "A0B1C2D3-E4F5-4678-9ABC-DEF012345678" + + mount = await service.get_or_create_agent_mount( + project_id=project_id, user_id=user_id, artifact_id=artifact_id + ) + + slug_id_segment = mount.slug.removeprefix("__ag__agent__").split("__", 1)[0] + assert mount.agent_id == slug_id_segment + assert mount.agent_id == "a0b1c2d3-e4f5-4678-9abc-def012345678" + + +@pytest.mark.asyncio +async def test_session_mount_leaves_agent_id_null(mount_context): + """Session mounts stay agent_id-null — only agent mounts populate it.""" + service, _, project_id, user_id = mount_context + + mount = await service.get_or_create_session_mount( + project_id=project_id, user_id=user_id, session_id="sess-1" + ) + + assert mount.session_id is not None + assert mount.agent_id is None + + +@pytest.mark.asyncio +async def test_query_mounts_filters_by_agent_id(mount_context): + """The mount query DTO/filter mirrors session_id: query mounts for agent X.""" + service, dao, project_id, user_id = mount_context + artifact_a = str(uuid4()) + artifact_b = str(uuid4()) + + mount_a = await service.get_or_create_agent_mount( + project_id=project_id, user_id=user_id, artifact_id=artifact_a + ) + await service.get_or_create_agent_mount( + project_id=project_id, user_id=user_id, artifact_id=artifact_b + ) + await service.get_or_create_session_mount( + project_id=project_id, user_id=user_id, session_id="sess-1" + ) + + results = await service.query_mounts( + project_id=project_id, + mount_query=MountQuery(agent_id=mint_agent_id(artifact_id=artifact_a)), + ) + + assert len(results) == 1 + assert results[0].id == mount_a.id + assert results[0].agent_id == mint_agent_id(artifact_id=artifact_a) diff --git a/api/oss/tests/pytest/unit/otlp/test_logfire_adapter.py b/api/oss/tests/pytest/unit/otlp/test_logfire_adapter.py index 304e5888eb..90ac86a5c8 100644 --- a/api/oss/tests/pytest/unit/otlp/test_logfire_adapter.py +++ b/api/oss/tests/pytest/unit/otlp/test_logfire_adapter.py @@ -715,6 +715,13 @@ def test_provider_name_mapped(self, adapter): assert features.meta["provider.name"] == "openai" + def test_agent_id_mapped_to_agent_feature(self, adapter): + bag = _make_bag({"gen_ai.agent.id": "agent-42"}) + features = SpanFeatures() + adapter.process(bag, features) + + assert features.agent["id"] == "agent-42" + # ── Edge Cases ────────────────────────────────────────────────────── diff --git a/api/oss/tests/pytest/unit/session_states/__init__.py b/api/oss/tests/pytest/unit/session_states/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/api/oss/tests/pytest/unit/session_states/test_harness_sessions_mapping.py b/api/oss/tests/pytest/unit/session_states/test_harness_sessions_mapping.py deleted file mode 100644 index 16e4681683..0000000000 --- a/api/oss/tests/pytest/unit/session_states/test_harness_sessions_mapping.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Unit tests for the S2/S2b durable continuity state on session_states. - -The continuity fields live inside the existing `data` JSON column (no dedicated columns), -typed as `SessionStateData`. Verifies: - - dbe_to_dto hydrates the raw `data` dict into a SessionStateData with a - Dict[str, HarnessSessionRecord]; - - a DBE with no `data` maps to None, not an empty model; - - SessionStateUpsert carries `data` through model_dump(exclude_unset=True) - (mirrors test_records_mapping_upsert.py's style: no DB, no live stack). -""" - -from uuid import UUID - -from oss.src.core.sessions.states.dtos import ( - HarnessSessionRecord, - SessionStateData, - SessionStateUpsert, -) -from oss.src.dbs.postgres.sessions.states.dbes import SessionStateDBE -from oss.src.dbs.postgres.sessions.states.mappings import dbe_to_dto - - -_PROJECT_ID = UUID("00000000-0000-0000-0000-0000000000aa") - - -def _dbe(**over): - base = dict( - project_id=_PROJECT_ID, - session_id="sess-1", - data=None, - sandbox_id=None, - flags=None, - tags=None, - meta=None, - ) - base.update(over) - return SessionStateDBE(**base) - - -def test_dbe_to_dto_hydrates_data_continuity_state(): - dbe = _dbe( - data={ - "latest_agent_session_id": "agent-claude-2", - "harness_sessions": { - "claude": { - "agent_session_id": "agent-claude-2", - "turn_index": 2, - }, - "pi": { - "agent_session_id": "agent-pi-1", - "turn_index": 1, - }, - }, - "latest_turn_index": 2, - }, - ) - - dto = dbe_to_dto(dbe) - - assert dto.data is not None - assert dto.data.latest_agent_session_id == "agent-claude-2" - assert dto.data.latest_turn_index == 2 - assert dto.data.harness_sessions is not None - assert set(dto.data.harness_sessions.keys()) == {"claude", "pi"} - assert isinstance(dto.data.harness_sessions["claude"], HarnessSessionRecord) - assert dto.data.harness_sessions["claude"].agent_session_id == "agent-claude-2" - assert dto.data.harness_sessions["claude"].turn_index == 2 - assert dto.data.harness_sessions["pi"].agent_session_id == "agent-pi-1" - assert dto.data.harness_sessions["pi"].turn_index == 1 - - -def test_dbe_to_dto_no_data_maps_to_none(): - dbe = _dbe() - - dto = dbe_to_dto(dbe) - - assert dto.data is None - - -def test_dbe_to_dto_empty_data_column_maps_to_none(): - # JSON(none_as_null=True): an empty {} column value is falsy, so the mapping's - # `if dbe.data else None` guard must resolve to None, not an empty model. - dbe = _dbe(data={}) - - dto = dbe_to_dto(dbe) - - assert dto.data is None - - -def test_session_state_upsert_carries_data_through_exclude_unset(): - upsert = SessionStateUpsert( - data=SessionStateData( - latest_agent_session_id="agent-claude-3", - harness_sessions={ - "claude": HarnessSessionRecord( - agent_session_id="agent-claude-3", turn_index=3 - ), - }, - latest_turn_index=3, - ), - ) - - dumped = upsert.model_dump(exclude_unset=True) - - assert dumped == { - "data": { - "latest_agent_session_id": "agent-claude-3", - "harness_sessions": { - "claude": { - "agent_session_id": "agent-claude-3", - "turn_index": 3, - }, - }, - "latest_turn_index": 3, - }, - } - - -def test_session_state_upsert_unset_data_is_excluded(): - # A caller that only updates sandbox_id must not accidentally clear `data`: - # exclude_unset=True means an untouched field is simply absent from the dump, not null. - upsert = SessionStateUpsert(sandbox_id="sbx-1") - - dumped = upsert.model_dump(exclude_unset=True) - - assert dumped == {"sandbox_id": "sbx-1"} - assert "data" not in dumped diff --git a/api/oss/tests/pytest/unit/session_states/test_session_id_validation.py b/api/oss/tests/pytest/unit/session_states/test_session_id_validation.py deleted file mode 100644 index 84cbb794c8..0000000000 --- a/api/oss/tests/pytest/unit/session_states/test_session_id_validation.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Unit tests for session_id shape validation.""" - -import pytest -from fastapi import HTTPException - -from oss.src.apis.fastapi.sessions.router import ( - _validate_session_id_http as _validate_session_id, -) - - -class TestSessionIdValidation: - def test_valid_uuid(self): - _validate_session_id("550e8400-e29b-41d4-a716-446655440000") - - def test_valid_slug(self): - _validate_session_id("my-session_01") - - def test_dotted_rejected(self): - with pytest.raises(HTTPException) as exc_info: - _validate_session_id("project.session.123") - assert exc_info.value.status_code == 400 - - def test_empty_rejected(self): - with pytest.raises(HTTPException) as exc_info: - _validate_session_id("") - assert exc_info.value.status_code == 400 - - def test_slash_rejected(self): - with pytest.raises(HTTPException) as exc_info: - _validate_session_id("foo/bar") - assert exc_info.value.status_code == 400 - - def test_space_rejected(self): - with pytest.raises(HTTPException) as exc_info: - _validate_session_id("foo bar") - assert exc_info.value.status_code == 400 - - def test_too_long_rejected(self): - with pytest.raises(HTTPException) as exc_info: - _validate_session_id("a" * 129) - assert exc_info.value.status_code == 400 - - def test_max_length_accepted(self): - _validate_session_id("a" * 128) diff --git a/api/oss/tests/pytest/unit/sessions/conftest.py b/api/oss/tests/pytest/unit/sessions/conftest.py new file mode 100644 index 0000000000..6f4a48387e --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/conftest.py @@ -0,0 +1,32 @@ +import socket +from functools import lru_cache +from urllib.parse import urlparse + +import pytest + +from oss.src.utils.env import env + + +@lru_cache(maxsize=1) +def _postgres_reachable() -> bool: + """TCP-probe the configured core Postgres once per session. + + The integration DAO tests here need a real Postgres. The default URI points + at the Docker-network host `postgres:5432`, which resolves in-compose/CI but + not on a bare host (`load-env` leaves it commented). Probe rather than error + so a native `py-run-tests --api` skips these instead of failing setup. + """ + parsed = urlparse(env.postgres.uri_core) + host = parsed.hostname or "postgres" + port = parsed.port or 5432 + try: + with socket.create_connection((host, port), timeout=0.5): + return True + except OSError: + return False + + +@pytest.fixture(autouse=True) +def _skip_when_postgres_unreachable(request): + if request.node.get_closest_marker("integration") and not _postgres_reachable(): + pytest.skip("Postgres not reachable — skipping session DAO integration tests") diff --git a/api/oss/tests/pytest/unit/sessions/test_command_matrix_inputs_data.py b/api/oss/tests/pytest/unit/sessions/test_command_matrix_inputs_data.py new file mode 100644 index 0000000000..d260c85e83 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_command_matrix_inputs_data.py @@ -0,0 +1,240 @@ +"""WP7 (W7.1/E6): the command discriminator is `data.inputs` present-or-not x `force`, +not `prompt`. + +Pins the matrix in `SessionStreamsService.command()` against the renamed +`SessionStreamCommandRequest.data` field (a `WorkflowServiceRequestData`, so the shape +aligns with `WorkflowInvokeRequest.data.inputs` rather than a bespoke string): + + data.inputs present + no force -> send (409 if alive) + data.inputs present + force -> steer (cancel holder, start new) + no data.inputs + no force -> cancel (cancel holder, run nothing) + no data.inputs + force -> attach (steal attached, watch) + +Also covers: an empty-dict `inputs` (falsy) is treated as "no inputs", same as a missing +`data`, matching the old `prompt` field's "blank string == no prompt" rule. +""" + +from typing import Optional +from unittest.mock import patch +from uuid import UUID, uuid4 + +import pytest +import pytest_asyncio + +from agenta.sdk.models.workflows import WorkflowServiceRequestData + +from oss.src.core.sessions.streams.dtos import ( + CommandMode, + SessionStream, + SessionStreamCommandRequest, +) +from oss.src.core.sessions.streams.service import SessionStreamsService +from oss.src.core.sessions.streams.types import SessionTurnInUse + +from unit.sessions.test_project_scoped_locks import _FakeRedis + + +_PROJECT = uuid4() +_USER = uuid4() + + +class _FakeStreamsDAO: + def __init__(self, existing: Optional[SessionStream] = None): + self.row = existing + + async def get_by_session_id(self, *, project_id: UUID, session_id: str): + return self.row + + async def create(self, *, project_id, user_id, stream): + self.row = SessionStream( + id=uuid4(), + project_id=project_id, + session_id=stream.session_id, + flags=stream.flags, + turn_id=stream.turn_id, + ) + return self.row + + async def update(self, *, project_id, user_id, session_id, stream): + prior = self.row + self.row = SessionStream( + id=prior.id if prior else uuid4(), + project_id=project_id, + session_id=session_id, + flags=stream.flags + if stream.flags is not None + else (prior.flags if prior else None), + turn_id=stream.turn_id + if stream.turn_id is not None + else (prior.turn_id if prior else None), + ) + return self.row + + async def delete_by_session_id(self, *, project_id, session_id): + return True + + +@pytest_asyncio.fixture +async def lock_engine(): + from oss.src.dbs.redis.shared.engine import LockEngine + + eng = LockEngine() + with patch.object(eng, "_client", return_value=_FakeRedis()): + yield eng + + +def _service(lock_engine, dao=None): + return SessionStreamsService( + streams_dao=dao or _FakeStreamsDAO(), lock_engine=lock_engine + ) + + +def _session_id() -> str: + return f"session_{uuid4().hex[:12]}" + + +@pytest.mark.asyncio +async def test_inputs_present_no_force_is_send(lock_engine): + svc = _service(lock_engine) + session_id = _session_id() + + result = await svc.command( + project_id=_PROJECT, + user_id=_USER, + request=SessionStreamCommandRequest( + session_id=session_id, + data=WorkflowServiceRequestData(inputs={"messages": ["hi"]}), + force=False, + ), + ) + + assert result.mode == CommandMode.send + assert result.turn_id is not None + + +@pytest.mark.asyncio +async def test_inputs_present_and_force_is_steer(lock_engine): + svc = _service(lock_engine) + session_id = _session_id() + + # Establish a holder first (a send), then steer over it. + await svc.command( + project_id=_PROJECT, + user_id=_USER, + request=SessionStreamCommandRequest( + session_id=session_id, + data=WorkflowServiceRequestData(inputs={"messages": ["first"]}), + force=False, + ), + ) + + result = await svc.command( + project_id=_PROJECT, + user_id=_USER, + request=SessionStreamCommandRequest( + session_id=session_id, + data=WorkflowServiceRequestData(inputs={"messages": ["steer"]}), + force=True, + ), + ) + + assert result.mode == CommandMode.steer + assert result.turn_id is not None + + +@pytest.mark.asyncio +async def test_no_inputs_no_force_is_cancel(lock_engine): + svc = _service(lock_engine) + session_id = _session_id() + + result = await svc.command( + project_id=_PROJECT, + user_id=_USER, + request=SessionStreamCommandRequest( + session_id=session_id, data=None, force=False + ), + ) + + assert result.mode == CommandMode.cancel + + +@pytest.mark.asyncio +async def test_no_inputs_and_force_is_attach(lock_engine): + svc = _service(lock_engine) + session_id = _session_id() + + result = await svc.command( + project_id=_PROJECT, + user_id=_USER, + request=SessionStreamCommandRequest( + session_id=session_id, data=None, force=True + ), + ) + + assert result.mode == CommandMode.attach + assert result.watcher_id is not None + + +@pytest.mark.asyncio +async def test_empty_inputs_dict_counts_as_no_inputs(lock_engine): + """An empty `inputs={}` is falsy, matching the old prompt field's blank-string rule.""" + svc = _service(lock_engine) + session_id = _session_id() + + result = await svc.command( + project_id=_PROJECT, + user_id=_USER, + request=SessionStreamCommandRequest( + session_id=session_id, + data=WorkflowServiceRequestData(inputs={}), + force=False, + ), + ) + + assert result.mode == CommandMode.cancel + + +@pytest.mark.asyncio +async def test_data_present_but_inputs_none_counts_as_no_inputs(lock_engine): + """`data` set but `data.inputs` unset (None) is still "no inputs".""" + svc = _service(lock_engine) + session_id = _session_id() + + result = await svc.command( + project_id=_PROJECT, + user_id=_USER, + request=SessionStreamCommandRequest( + session_id=session_id, + data=WorkflowServiceRequestData(), + force=True, + ), + ) + + assert result.mode == CommandMode.attach + + +@pytest.mark.asyncio +async def test_send_with_inputs_409s_when_already_alive(lock_engine): + svc = _service(lock_engine) + session_id = _session_id() + + await svc.command( + project_id=_PROJECT, + user_id=_USER, + request=SessionStreamCommandRequest( + session_id=session_id, + data=WorkflowServiceRequestData(inputs={"messages": ["first"]}), + force=False, + ), + ) + + with pytest.raises(SessionTurnInUse): + await svc.command( + project_id=_PROJECT, + user_id=_USER, + request=SessionStreamCommandRequest( + session_id=session_id, + data=WorkflowServiceRequestData(inputs={"messages": ["second"]}), + force=False, + ), + ) diff --git a/api/oss/tests/pytest/unit/sessions/test_heartbeat_is_current_turn.py b/api/oss/tests/pytest/unit/sessions/test_heartbeat_is_current_turn.py new file mode 100644 index 0000000000..4f72336691 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_heartbeat_is_current_turn.py @@ -0,0 +1,177 @@ +"""WP7 (W7.4): the control signal from cancel/steer/kill must reach the runner's heartbeat. + +Before this, `heartbeat()`'s acquire-then-refresh fallback silently re-acquired a lost alive +lock under the SAME turn_id (nx=True is a no-op only when the key is gone) — a cancel/steer/ +kill that raced a heartbeat was invisible to the runner: the beat still looked like a normal +`ok` heartbeat. `is_current_turn` on `SessionHeartbeatResult` surfaces the interruption so the +runner's watchdog can abort the in-flight run (`services/runner/src/sessions/alive.ts`'s +`onInterrupted`). + +Covers: + - a normal heartbeat sequence (no interruption) reports is_current_turn=True throughout; + - a cancel between two heartbeats of the SAME turn_id flips the next beat's + is_current_turn to False (the lock was gone, then silently re-acquired); + - a steer (different turn_id takes the lock) also reports the OLD turn's next beat as + is_current_turn=False, and does not steal the lock back for the old turn; + - a replica that lost the owner claim entirely reports is_current_turn=False. +""" + +from typing import Optional +from unittest.mock import patch +from uuid import UUID, uuid4 + +import pytest +import pytest_asyncio + +from oss.src.core.sessions.streams.dtos import ( + SessionHeartbeatRequest, + SessionStream, +) +from oss.src.core.sessions.streams.service import SessionStreamsService +from oss.src.dbs.redis.sessions.locks import force_cancel_alive, get_alive_owner + +from unit.sessions.test_project_scoped_locks import _FakeRedis + + +_PROJECT = uuid4() +_SESSION = "session_interrupt" + + +class _FakeStreamsDAO: + def __init__(self, existing: Optional[SessionStream] = None): + self.row = existing + + async def get_by_session_id(self, *, project_id: UUID, session_id: str): + return self.row + + async def create(self, *, project_id, user_id, stream): + self.row = SessionStream( + id=uuid4(), + project_id=project_id, + session_id=stream.session_id, + flags=stream.flags, + turn_id=stream.turn_id, + ) + return self.row + + async def update(self, *, project_id, user_id, session_id, stream): + prior = self.row + self.row = SessionStream( + id=prior.id if prior else uuid4(), + project_id=project_id, + session_id=session_id, + flags=stream.flags + if stream.flags is not None + else (prior.flags if prior else None), + turn_id=stream.turn_id + if stream.turn_id is not None + else (prior.turn_id if prior else None), + ) + return self.row + + async def delete_by_session_id(self, *, project_id, session_id): + return True + + +@pytest_asyncio.fixture +async def lock_engine(): + from oss.src.dbs.redis.shared.engine import LockEngine + + eng = LockEngine() + with patch.object(eng, "_client", return_value=_FakeRedis()): + yield eng + + +def _service(lock_engine, dao=None): + return SessionStreamsService( + streams_dao=dao or _FakeStreamsDAO(), lock_engine=lock_engine + ) + + +def _beat(replica: str, turn: str, running: bool = True) -> SessionHeartbeatRequest: + return SessionHeartbeatRequest( + session_id=_SESSION, replica_id=replica, turn_id=turn, is_running=running + ) + + +@pytest.mark.asyncio +async def test_uninterrupted_heartbeats_stay_current(lock_engine): + svc = _service(lock_engine) + + r1 = await svc.heartbeat(project_id=_PROJECT, request=_beat("replica-a", "turn-1")) + r2 = await svc.heartbeat(project_id=_PROJECT, request=_beat("replica-a", "turn-1")) + + assert r1.is_current_turn is True + assert r2.is_current_turn is True + + +@pytest.mark.asyncio +async def test_cancel_between_beats_flips_next_beat_to_not_current(lock_engine): + svc = _service(lock_engine) + + first = await svc.heartbeat( + project_id=_PROJECT, request=_beat("replica-a", "turn-1") + ) + assert first.is_current_turn is True + + # A cancel/kill force-clears the alive lock out from under the still-running turn. + await force_cancel_alive(lock_engine, project_id=str(_PROJECT), session_id=_SESSION) + + second = await svc.heartbeat( + project_id=_PROJECT, request=_beat("replica-a", "turn-1") + ) + + assert second.is_current_turn is False, ( + "a beat after the lock was force-cancelled must report the interruption, even " + "though the nx=True re-acquire silently re-establishes the SAME lock" + ) + # The re-acquire still happens (the session stays alive/reattachable) — only the + # bookkeeping bit changes. + assert ( + await get_alive_owner( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION + ) + == "turn-1" + ) + + +@pytest.mark.asyncio +async def test_steer_flips_the_old_turns_next_beat_to_not_current(lock_engine): + svc = _service(lock_engine) + + await svc.heartbeat(project_id=_PROJECT, request=_beat("replica-a", "turn-1")) + + # Simulate a steer: a new turn steals the alive/running locks (force_cancel + a fresh + # acquire under turn-2), mirroring what command()'s steer branch does. + await force_cancel_alive(lock_engine, project_id=str(_PROJECT), session_id=_SESSION) + await svc.heartbeat(project_id=_PROJECT, request=_beat("replica-a", "turn-2")) + + # The OLD turn's own heartbeat (still in flight on the runner) must see the takeover. + old_turn_beat = await svc.heartbeat( + project_id=_PROJECT, request=_beat("replica-a", "turn-1") + ) + + assert old_turn_beat.is_current_turn is False + # And it must NOT have stolen the lock back for turn-1 (nx=True fails because turn-2 + # already holds it) — the new turn keeps the lock. + assert ( + await get_alive_owner( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION + ) + == "turn-2" + ) + + +@pytest.mark.asyncio +async def test_losing_owner_claim_reports_not_current(lock_engine): + svc = _service(lock_engine) + + await svc.heartbeat(project_id=_PROJECT, request=_beat("replica-a", "turn-1")) + + # A second replica heartbeats the same session; claim_owner never steals, so it loses. + result = await svc.heartbeat( + project_id=_PROJECT, request=_beat("replica-b", "turn-2") + ) + + assert result.is_current_turn is False + assert result.replica_id == "replica-a" diff --git a/api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py b/api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py index 6d2c4c7f7a..8088fb29ee 100644 --- a/api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py +++ b/api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py @@ -5,6 +5,9 @@ from unittest.mock import AsyncMock, MagicMock +from oss.src.apis.fastapi.sessions.models import SessionInteractionCreateRequest +from oss.src.core.sessions.interactions.dtos import SessionInteractionKind + from oss.src.tasks.asyncio.sessions.interactions_dispatcher import ( InteractionsDispatcher, ) @@ -31,6 +34,19 @@ def _make_interaction(*, with_refs=True): ) +def test_create_request_accepts_omitted_workflow_references(): + request = SessionInteractionCreateRequest( + session_id="sess-no-refs", + turn_id="turn-no-refs", + token="token-no-refs", + kind=SessionInteractionKind.user_approval, + data={"request": {"tool": "bash", "args": {"command": "pwd"}}}, + ) + + assert request.data is not None + assert request.data.references is None + + async def test_respond_fallback_calls_invoke_when_no_dispatch_fn(): interaction = _make_interaction() project_id = uuid4() @@ -60,6 +76,35 @@ async def test_respond_fallback_calls_invoke_when_no_dispatch_fn(): assert invoke_kwargs["user_id"] == user_id +async def test_respond_without_references_builds_a_safe_reference_less_request(): + interaction = _make_interaction(with_refs=False) + project_id = uuid4() + user_id = uuid4() + + interactions_service = MagicMock() + interactions_service.fetch_interaction = AsyncMock(return_value=interaction) + + workflows_service = MagicMock() + workflows_service.invoke_workflow = AsyncMock(return_value=SimpleNamespace()) + + worker = InteractionsDispatcher( + workflows_service=workflows_service, + interactions_service=interactions_service, + ) + + await worker.respond( + project_id=project_id, + user_id=user_id, + interaction_id=interaction.id, + answer={"approved": True}, + ) + + workflows_service.invoke_workflow.assert_awaited_once() + invoke_request = workflows_service.invoke_workflow.await_args.kwargs["request"] + assert invoke_request.references is None + assert invoke_request.session_id == interaction.session_id + + async def test_respond_detached_calls_dispatch_fn_not_invoke(): interaction = _make_interaction() project_id = uuid4() diff --git a/api/oss/tests/pytest/unit/sessions/test_interactions_transition_update.py b/api/oss/tests/pytest/unit/sessions/test_interactions_transition_update.py new file mode 100644 index 0000000000..47a96107a4 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_interactions_transition_update.py @@ -0,0 +1,80 @@ +from uuid import uuid4 + +from sqlalchemy.dialects import postgresql + +from oss.src.core.sessions.interactions.dtos import ( + SessionInteractionStatus, + SessionInteractionTransition, +) +from oss.src.dbs.postgres.sessions.interactions.dao import SessionInteractionsDAO + + +class _Result: + def scalar_one_or_none(self): + return None + + +class _Session: + def __init__(self): + self.statement = None + + async def execute(self, statement): + self.statement = statement + return _Result() + + async def commit(self): + return None + + +class _SessionContext: + def __init__(self, session): + self.session = session + + async def __aenter__(self): + return self.session + + async def __aexit__(self, exc_type, exc, traceback): + return False + + +class _Engine: + def __init__(self, session): + self._session = session + + def session(self): + return _SessionContext(self._session) + + +async def _transition_statement(resolution=None): + session = _Session() + dao = SessionInteractionsDAO(engine=_Engine(session)) + await dao.transition_interaction( + transition=SessionInteractionTransition( + project_id=uuid4(), + session_id="session-1", + token="token-1", + status=SessionInteractionStatus.resolved, + resolution=resolution, + ) + ) + return session.statement + + +async def test_resolution_update_preserves_existing_data_with_jsonb_set(): + resolution = {"verdict": "approved", "tool_call_id": "tool-1"} + statement = await _transition_statement(resolution) + compiled = statement.compile(dialect=postgresql.dialect()) + set_clause = str(compiled).split(" WHERE ", maxsplit=1)[0] + + assert "jsonb_set" in set_clause + assert "CAST(session_interactions.data AS JSONB)" in set_clause + assert resolution in compiled.params.values() + + +async def test_transition_without_resolution_does_not_write_data(): + statement = await _transition_statement() + set_clause = str(statement.compile(dialect=postgresql.dialect())).split( + " WHERE ", maxsplit=1 + )[0] + + assert "data=" not in set_clause diff --git a/api/oss/tests/pytest/unit/sessions/test_kill_runner_teardown.py b/api/oss/tests/pytest/unit/sessions/test_kill_runner_teardown.py new file mode 100644 index 0000000000..aaf495dc6a --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_kill_runner_teardown.py @@ -0,0 +1,122 @@ +"""WP7 (W7.3): kill must reach the runner's sandbox teardown, not just the Redis/row edit. + +Before this, `SessionStreamsService.kill()` only force-cleared the Redis nest and soft-deleted +the stream row — nothing called the runner's `POST /kill` (`services/runner/src/server.ts`), +so a live sandbox kept running until its own idle-TTL eviction. `kill_runner_sandbox` +(core/sessions/streams/runner_client.py) closes that gap; these tests pin: + - kill() calls it exactly once, scoped to (project_id, session_id); + - a runner-client failure never blocks kill's Redis/row edit (best-effort); + - kill remains idempotent when the runner call fails. +""" + +from typing import Optional +from unittest.mock import patch +from uuid import UUID, uuid4 + +import pytest +import pytest_asyncio + +from oss.src.core.sessions.streams.dtos import SessionStream +from oss.src.core.sessions.streams.service import SessionStreamsService + +from unit.sessions.test_project_scoped_locks import _FakeRedis + + +_PROJECT = uuid4() +_USER = uuid4() +_SESSION = "session_kill_teardown" + + +class _FakeStreamsDAO: + def __init__(self, existing: Optional[SessionStream] = None): + self.row = existing + self.deleted = 0 + + async def get_by_session_id(self, *, project_id: UUID, session_id: str): + return self.row + + async def create(self, *, project_id, user_id, stream): + self.row = SessionStream( + id=uuid4(), + project_id=project_id, + session_id=stream.session_id, + flags=stream.flags, + turn_id=stream.turn_id, + ) + return self.row + + async def update(self, *, project_id, user_id, session_id, stream): + prior = self.row + self.row = SessionStream( + id=prior.id if prior else uuid4(), + project_id=project_id, + session_id=session_id, + flags=stream.flags + if stream.flags is not None + else (prior.flags if prior else None), + ) + return self.row + + async def delete_by_session_id(self, *, project_id, session_id): + self.deleted += 1 + return True + + +@pytest_asyncio.fixture +async def lock_engine(): + from oss.src.dbs.redis.shared.engine import LockEngine + + eng = LockEngine() + with patch.object(eng, "_client", return_value=_FakeRedis()): + yield eng + + +@pytest.mark.asyncio +async def test_kill_calls_runner_client_scoped_to_project_and_session(lock_engine): + dao = _FakeStreamsDAO() + svc = SessionStreamsService(streams_dao=dao, lock_engine=lock_engine) + + with patch( + "oss.src.core.sessions.streams.service.kill_runner_sandbox" + ) as mock_kill: + mock_kill.return_value = True + ok = await svc.kill(project_id=_PROJECT, user_id=_USER, session_id=_SESSION) + + assert ok is True + mock_kill.assert_awaited_once_with(project_id=str(_PROJECT), session_id=_SESSION) + assert dao.deleted == 1 + + +@pytest.mark.asyncio +async def test_kill_still_succeeds_when_runner_call_fails(lock_engine): + """Best-effort: the runner being unreachable must not block the Redis/row edit.""" + dao = _FakeStreamsDAO() + svc = SessionStreamsService(streams_dao=dao, lock_engine=lock_engine) + + with patch( + "oss.src.core.sessions.streams.service.kill_runner_sandbox" + ) as mock_kill: + mock_kill.return_value = False + ok = await svc.kill(project_id=_PROJECT, user_id=_USER, session_id=_SESSION) + + assert ok is True, ( + "kill's Redis/row edit must succeed regardless of runner reachability" + ) + assert dao.deleted == 1 + + +@pytest.mark.asyncio +async def test_kill_is_idempotent_across_repeated_calls(lock_engine): + dao = _FakeStreamsDAO() + svc = SessionStreamsService(streams_dao=dao, lock_engine=lock_engine) + + with patch( + "oss.src.core.sessions.streams.service.kill_runner_sandbox" + ) as mock_kill: + mock_kill.return_value = True + first = await svc.kill(project_id=_PROJECT, user_id=_USER, session_id=_SESSION) + second = await svc.kill(project_id=_PROJECT, user_id=_USER, session_id=_SESSION) + + assert first is True + assert second is True + assert mock_kill.await_count == 2 diff --git a/api/oss/tests/pytest/unit/sessions/test_mounts_agent_id_backfill.py b/api/oss/tests/pytest/unit/sessions/test_mounts_agent_id_backfill.py new file mode 100644 index 0000000000..bcf6c4d5e6 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_mounts_agent_id_backfill.py @@ -0,0 +1,69 @@ +"""Migration test for the mounts.agent_id backfill (oss000000016_add_mounts_agent_id). + +The migration recovers agent_id straight out of an agent-mount slug +(`__ag__agent____`) with +`split_part(substr(slug, 14), '__', 1)`, guarded by +`left(slug, 13) = '__ag__agent__'`. Session-mount slugs +(`__ag__session____`) hash the session id, so they must stay agent_id-null. + +This runs the migration's exact SQL expression against Postgres (no table/FK needed — the +extraction is over literal slugs) so a drift in the expression fails here instead of +silently mis-backfilling. Mirrors TEST-GAPS.md §"mounts.agent_id backfill". +""" + +import uuid + +import pytest +from sqlalchemy import text + +import oss.src.dbs.postgres.shared.engine as engine_module +from oss.src.dbs.postgres.shared.engine import get_transactions_engine + + +pytestmark = pytest.mark.integration + +# Verbatim from the migration: `left(slug, 13) = '__ag__agent__'` guards, and +# `split_part(substr(slug, 14), '__', 1)` extracts. +_BACKFILL_EXPR = ( + "CASE WHEN left(:slug, 13) = '__ag__agent__' " + "THEN split_part(substr(:slug, 14), '__', 1) ELSE NULL END" +) + + +@pytest.fixture(autouse=True) +async def _fresh_engine_per_test(): + engine_module._transactions_engine = None + yield + if engine_module._transactions_engine is not None: + await engine_module._transactions_engine.close() + engine_module._transactions_engine = None + + +async def _extract(slug: str): + engine = get_transactions_engine() + async with engine.session() as session: + result = await session.execute( + text(f"SELECT {_BACKFILL_EXPR} AS agent_id"), {"slug": slug} + ) + return result.scalar_one() + + +async def test_agent_slug_backfills_the_canonical_artifact_id(): + artifact_id = str(uuid.uuid4()) + slug = f"__ag__agent__{artifact_id}__my-agent" + + assert await _extract(slug) == artifact_id + + +async def test_agent_slug_with_underscores_in_name_keeps_only_the_id(): + artifact_id = str(uuid.uuid4()) + # The name segment may itself contain the `__` separator; only the first field is the id. + slug = f"__ag__agent__{artifact_id}__my__weird__name" + + assert await _extract(slug) == artifact_id + + +async def test_session_mount_slug_stays_agent_id_null(): + slug = f"__ag__session__{uuid.uuid4().hex}__claude-projects" + + assert await _extract(slug) is None diff --git a/api/oss/tests/pytest/unit/sessions/test_record_ingest_endpoint.py b/api/oss/tests/pytest/unit/sessions/test_record_ingest_endpoint.py index 10b98ffc46..2ee452d4b4 100644 --- a/api/oss/tests/pytest/unit/sessions/test_record_ingest_endpoint.py +++ b/api/oss/tests/pytest/unit/sessions/test_record_ingest_endpoint.py @@ -100,3 +100,99 @@ async def test_record_ingest_rejects_without_permission(): await router.ingest_record_event(request=request, body=body) assert exc_info.value.status_code == 403 + + +async def test_record_ingest_threads_turn_id_and_span_id(): + records_service = AsyncMock() + router = RecordsRouter(records_service=records_service) + + project_id = uuid4() + user_id = uuid4() + organization_id = uuid4() + session_id = uuid4() + # The runner posts a 16-hex OTel span id, NOT a UUID (regression: it was typed as UUID + # and every ingest 422'd — see FINDING-record-ingest-422.md). + span_id = uuid4().hex[:16] + + body = SessionRecordIngestRequest( + session_id=str(session_id), + record_index=0, + record_source="agent", + attributes={"type": "message"}, + turn_id="turn-abc", + span_id=span_id, + ) + + app = FastAPI() + request = _make_authed_request(app, project_id, user_id, organization_id) + + with ( + patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=True, + ), + patch( + "oss.src.apis.fastapi.sessions.router.publish_record", + new_callable=AsyncMock, + return_value=True, + ) as mock_publish, + ): + await router.ingest_record_event(request=request, body=body) + + event = mock_publish.await_args.kwargs["record_event"] + assert event.turn_id == "turn-abc" + assert event.span_id == span_id + + +async def test_record_ingest_defaults_turn_id_and_span_id_to_none(): + records_service = AsyncMock() + router = RecordsRouter(records_service=records_service) + + project_id = uuid4() + user_id = uuid4() + organization_id = uuid4() + session_id = uuid4() + + body = SessionRecordIngestRequest(session_id=str(session_id)) + + app = FastAPI() + request = _make_authed_request(app, project_id, user_id, organization_id) + + with ( + patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=True, + ), + patch( + "oss.src.apis.fastapi.sessions.router.publish_record", + new_callable=AsyncMock, + return_value=True, + ) as mock_publish, + ): + await router.ingest_record_event(request=request, body=body) + + event = mock_publish.await_args.kwargs["record_event"] + assert event.turn_id is None + assert event.span_id is None + + +def test_ingest_model_accepts_otel_span_id_and_rejects_uuid(): + """Contract pin for the 422 regression: the ingest model accepts the 16-hex OTel span id + the runner actually sends, and rejects a 32-hex UUID (which is what the field used to be + typed as — that mistyping made pydantic reject every real ingest with a 422).""" + from pydantic import ValidationError + + session_id = str(uuid4()) + + # The real runner shape: a 16-hex span id validates. + body = SessionRecordIngestRequest( + session_id=session_id, + span_id="a1b2c3d4e5f6a7b8", + ) + assert body.span_id == "a1b2c3d4e5f6a7b8" + + # A 32-hex UUID string is NOT a span id and must be rejected. + with pytest.raises(ValidationError): + SessionRecordIngestRequest(session_id=session_id, span_id=uuid4().hex) diff --git a/api/oss/tests/pytest/unit/sessions/test_records_mapping_upsert.py b/api/oss/tests/pytest/unit/sessions/test_records_mapping_upsert.py index 7e00300e75..775d146fce 100644 --- a/api/oss/tests/pytest/unit/sessions/test_records_mapping_upsert.py +++ b/api/oss/tests/pytest/unit/sessions/test_records_mapping_upsert.py @@ -44,6 +44,20 @@ def test_absent_record_id_falls_back_to_uuid4(): assert dbe.record_id.version == 4 +def test_turn_id_and_span_id_map_through_to_the_dbe(): + # A 16-hex OTel span id (what the runner sends), NOT a UUID. + span_id = uuid5(_RECORDS_NS, "span").hex[:16] + dbe = map_record_event_to_dbe(event=_event(turn_id="turn-1", span_id=span_id)) + assert dbe.turn_id == "turn-1" + assert dbe.span_id == span_id + + +def test_turn_id_and_span_id_default_to_none(): + dbe = map_record_event_to_dbe(event=_event()) + assert dbe.turn_id is None + assert dbe.span_id is None + + class _FakeResult: def scalars(self): class _S: @@ -117,6 +131,9 @@ async def _cm(): # payload columns are overwritten; the ordinal is not in the update set. assert "attributes" in compiled assert "set record_index" not in compiled + # turn_id/span_id ride the same upsert as the other payload columns. + assert "turn_id" in compiled + assert "span_id" in compiled def test_append_commits_when_it_opens_its_own_session(): diff --git a/api/oss/tests/pytest/unit/sessions/test_records_turn_span_dao.py b/api/oss/tests/pytest/unit/sessions/test_records_turn_span_dao.py new file mode 100644 index 0000000000..54e6113e52 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_records_turn_span_dao.py @@ -0,0 +1,194 @@ +"""Integration-style tests for records turn_id/span_id tagging against a real Postgres. + +Requires the tracing_oss migration chain applied (through oss000000003_add_records_turn_span) +and POSTGRES_URI_TRACING pointed at that database. Records have no FK to any other table +(cross-DB, plain columns), so no fixture chain is needed beyond project_id/session_id. + +Verifies: + - a new record carries turn_id (and span_id when supplied); + - records group by turn_id (client-side grouping over get_records, since there is no + server-side aggregate endpoint); + - span_id is populated when present on the event, null otherwise; + - old records (turn_id/span_id both null, as if written before this column existed) + are still readable under the session, alongside newer tagged ones. +""" + +import uuid +from datetime import datetime, timezone + +import pytest + +from oss.src.core.sessions.records.dtos import SessionRecordEvent +from oss.src.dbs.postgres.sessions.records.dao import RecordsDAO +import oss.src.dbs.postgres.shared.engine as engine_module +from oss.src.dbs.postgres.shared.engine import get_analytics_engine + + +pytestmark = pytest.mark.integration + + +@pytest.fixture(autouse=True) +async def _fresh_engine_per_test(): + """Each pytest-asyncio test gets its own event loop; the module-level engine + singleton binds its asyncpg pool to the first loop that touches it. Reset it + so every test starts with a pool bound to its own loop.""" + engine_module._analytics_engine = None + yield + if engine_module._analytics_engine is not None: + await engine_module._analytics_engine.close() + engine_module._analytics_engine = None + + +def _ids(): + return uuid.uuid4(), f"records-turn-span-test-{uuid.uuid4().hex[:8]}" + + +async def test_new_record_carries_turn_id_and_span_id(): + project_id, session_id = _ids() + turn_id = f"turn-{uuid.uuid4().hex[:8]}" + # 16-hex OTel span id (runner shape), NOT a UUID. + span_id = uuid.uuid4().hex[:16] + dao = RecordsDAO(engine=get_analytics_engine()) + + record = await dao.append( + event=SessionRecordEvent( + project_id=project_id, + session_id=session_id, + record_index=0, + record_type="message", + record_source="agent", + attributes={"text": "hello"}, + turn_id=turn_id, + span_id=span_id, + ) + ) + + assert record is not None + assert record.turn_id == turn_id + assert record.span_id == span_id + + fetched = await dao.get_records(project_id=project_id, session_id=session_id) + assert len(fetched) == 1 + assert fetched[0].turn_id == turn_id + assert fetched[0].span_id == span_id + + +async def test_span_id_null_when_not_supplied(): + project_id, session_id = _ids() + turn_id = f"turn-{uuid.uuid4().hex[:8]}" + dao = RecordsDAO(engine=get_analytics_engine()) + + record = await dao.append( + event=SessionRecordEvent( + project_id=project_id, + session_id=session_id, + record_index=0, + record_type="message", + record_source="agent", + attributes={"text": "no span here"}, + turn_id=turn_id, + ) + ) + + assert record is not None + assert record.turn_id == turn_id + assert record.span_id is None + + +async def test_records_group_by_turn_id(): + """No server-side aggregate endpoint (E1) — group-by-turn is client-side grouping + over get_records, which returns turn_id on every row for exactly this purpose.""" + project_id, session_id = _ids() + turn_a = f"turn-a-{uuid.uuid4().hex[:8]}" + turn_b = f"turn-b-{uuid.uuid4().hex[:8]}" + dao = RecordsDAO(engine=get_analytics_engine()) + + events = [ + SessionRecordEvent( + project_id=project_id, + session_id=session_id, + record_index=0, + record_type="message", + record_source="user", + attributes={"text": "turn a msg 1"}, + turn_id=turn_a, + ), + SessionRecordEvent( + project_id=project_id, + session_id=session_id, + record_index=1, + record_type="message", + record_source="agent", + attributes={"text": "turn a msg 2"}, + turn_id=turn_a, + ), + SessionRecordEvent( + project_id=project_id, + session_id=session_id, + record_index=0, + record_type="message", + record_source="user", + attributes={"text": "turn b msg 1"}, + turn_id=turn_b, + ), + ] + await dao.append_many(events=events) + + fetched = await dao.get_records(project_id=project_id, session_id=session_id) + assert len(fetched) == 3 + + grouped: dict = {} + for r in fetched: + grouped.setdefault(r.turn_id, []).append(r) + + assert set(grouped.keys()) == {turn_a, turn_b} + assert len(grouped[turn_a]) == 2 + assert len(grouped[turn_b]) == 1 + + +async def test_old_records_with_null_turn_id_still_readable_under_session(): + """Forward-fill only (tracing-DB rule, no backfill): a record written without + turn_id/span_id (simulating a pre-WP4 row) stays null and is still readable + alongside newer tagged records under the same session.""" + project_id, session_id = _ids() + turn_id = f"turn-{uuid.uuid4().hex[:8]}" + dao = RecordsDAO(engine=get_analytics_engine()) + + # "Old" record: no turn_id/span_id at all, as pre-WP4 producers would have sent. + old_record = await dao.append( + event=SessionRecordEvent( + project_id=project_id, + session_id=session_id, + record_index=0, + timestamp=datetime(2026, 1, 1, tzinfo=timezone.utc), + record_type="message", + record_source="user", + attributes={"text": "pre-existing record"}, + ) + ) + # "New" record: carries both. + new_record = await dao.append( + event=SessionRecordEvent( + project_id=project_id, + session_id=session_id, + record_index=1, + record_type="message", + record_source="agent", + attributes={"text": "post-WP4 record"}, + turn_id=turn_id, + span_id=uuid.uuid4().hex[:16], + ) + ) + + assert old_record.turn_id is None + assert old_record.span_id is None + assert new_record.turn_id == turn_id + + fetched = await dao.get_records(project_id=project_id, session_id=session_id) + ids = {r.record_id for r in fetched} + assert old_record.record_id in ids + assert new_record.record_id in ids + + by_id = {r.record_id: r for r in fetched} + assert by_id[old_record.record_id].turn_id is None + assert by_id[new_record.record_id].turn_id == turn_id diff --git a/api/oss/tests/pytest/unit/sessions/test_runner_client_kill.py b/api/oss/tests/pytest/unit/sessions/test_runner_client_kill.py new file mode 100644 index 0000000000..1966fdbeff --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_runner_client_kill.py @@ -0,0 +1,122 @@ +"""WP7 (W7.3): `kill_runner_sandbox`'s own contract — the direct API -> runner `/kill` hop. + +Covers: not configured -> no-op False; success -> True with the right URL/body/headers; +non-2xx -> False; a transport exception -> False (never raises). +""" + +from unittest.mock import patch + +import httpx +import pytest + +from oss.src.core.sessions.streams.runner_client import kill_runner_sandbox + + +class _FakeRunnerEnv: + def __init__(self, internal_url=None, token=None): + self.internal_url = internal_url + self.token = token + + +@pytest.mark.asyncio +async def test_returns_false_when_not_configured(): + with patch("oss.src.core.sessions.streams.runner_client.env") as mock_env: + mock_env.runner = _FakeRunnerEnv(internal_url=None, token=None) + result = await kill_runner_sandbox(project_id="proj-1", session_id="sess-1") + + assert result is False + + +@pytest.mark.asyncio +async def test_posts_to_kill_with_scoped_body_and_bearer_header(): + captured = {} + + class _FakeResponse: + status_code = 200 + + class _FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def post(self, url, json=None, headers=None): + captured["url"] = url + captured["json"] = json + captured["headers"] = headers + return _FakeResponse() + + with ( + patch("oss.src.core.sessions.streams.runner_client.env") as mock_env, + patch( + "oss.src.core.sessions.streams.runner_client.httpx.AsyncClient", + return_value=_FakeClient(), + ), + ): + mock_env.runner = _FakeRunnerEnv( + internal_url="http://runner:8765", token="shared-secret" + ) + result = await kill_runner_sandbox(project_id="proj-1", session_id="sess-1") + + assert result is True + assert captured["url"] == "http://runner:8765/kill" + assert captured["json"] == {"sessionId": "sess-1", "projectId": "proj-1"} + assert captured["headers"]["Authorization"] == "Bearer shared-secret" + + +@pytest.mark.asyncio +async def test_returns_false_on_non_2xx(): + class _FakeResponse: + status_code = 500 + + class _FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def post(self, *a, **kw): + return _FakeResponse() + + with ( + patch("oss.src.core.sessions.streams.runner_client.env") as mock_env, + patch( + "oss.src.core.sessions.streams.runner_client.httpx.AsyncClient", + return_value=_FakeClient(), + ), + ): + mock_env.runner = _FakeRunnerEnv( + internal_url="http://runner:8765", token="shared-secret" + ) + result = await kill_runner_sandbox(project_id="proj-1", session_id="sess-1") + + assert result is False + + +@pytest.mark.asyncio +async def test_returns_false_on_transport_error_never_raises(): + class _FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def post(self, *a, **kw): + raise httpx.ConnectError("connection refused") + + with ( + patch("oss.src.core.sessions.streams.runner_client.env") as mock_env, + patch( + "oss.src.core.sessions.streams.runner_client.httpx.AsyncClient", + return_value=_FakeClient(), + ), + ): + mock_env.runner = _FakeRunnerEnv( + internal_url="http://runner:8765", token="shared-secret" + ) + result = await kill_runner_sandbox(project_id="proj-1", session_id="sess-1") + + assert result is False diff --git a/api/oss/tests/pytest/unit/sessions/test_sessions_root_service.py b/api/oss/tests/pytest/unit/sessions/test_sessions_root_service.py new file mode 100644 index 0000000000..9bb561536d --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_sessions_root_service.py @@ -0,0 +1,332 @@ +"""WP5 (S7/F1/F2/B3): SessionsService — root /sessions operations. + +Unit-level: fakes each sub-service (streams/turns/interactions/mounts) so the +orchestration itself is under test, not the DAOs (those have their own +DAO-level integration coverage, e.g. test_turns_dao.py). Covers: + + - delete_session: exact fan-out order and calls (turns, interactions, mounts, + then the hard stream delete) — records are never touched. + - archive_session / unarchive_session: soft fan-out including bound mounts, + round-trips deleted_at. + - query_sessions: reference filter joins turns -> session_ids, then filters + the stream query; windowed pass-through; no-match short-circuits to []. + - fan-out keys off session_id, never stream_id (asserted via the fakes' + captured call kwargs). +""" + +from typing import List, Optional +from uuid import uuid4 + +import pytest + +from oss.src.core.sessions.dtos import SessionQuery +from oss.src.core.sessions.service import SessionsService +from oss.src.core.sessions.streams.dtos import SessionStream +from oss.src.core.sessions.turns.dtos import HarnessKind, SessionTurn +from oss.src.core.shared.dtos import Reference, Windowing + + +_PROJECT = uuid4() +_USER = uuid4() +_SESSION = "session-wp5-root" + + +def _stream(session_id: str = _SESSION, deleted: bool = False) -> SessionStream: + return SessionStream( + id=uuid4(), + project_id=_PROJECT, + session_id=session_id, + deleted_at="2026-01-01T00:00:00Z" if deleted else None, + ) + + +def _turn(session_id: str, references: Optional[List[Reference]] = None) -> SessionTurn: + return SessionTurn( + id=uuid4(), + project_id=_PROJECT, + session_id=session_id, + stream_id=uuid4(), + turn_index=0, + harness_kind=HarnessKind.PI, + references=references, + ) + + +class _FakeStreamsService: + def __init__(self, row: Optional[SessionStream] = None): + self.row = row + self.hard_delete_calls: list[dict] = [] + self.archive_calls: list[dict] = [] + self.unarchive_calls: list[dict] = [] + self.query_calls: list[dict] = [] + + async def query_streams( + self, *, project_id, filter, windowing=None, session_ids=None + ): + self.query_calls.append( + { + "project_id": project_id, + "session_ids": session_ids, + "windowing": windowing, + } + ) + if session_ids is not None: + return [ + s + for s in ([self.row] if self.row else []) + if s.session_id in session_ids + ] + return [self.row] if self.row else [] + + async def hard_delete(self, *, project_id, session_id): + self.hard_delete_calls.append( + {"project_id": project_id, "session_id": session_id} + ) + return True + + async def archive(self, *, project_id, session_id): + self.archive_calls.append({"project_id": project_id, "session_id": session_id}) + if self.row is not None: + self.row = self.row.model_copy( + update={"deleted_at": "2026-01-01T00:00:00Z"} + ) + return self.row + + async def unarchive(self, *, project_id, user_id, session_id): + self.unarchive_calls.append( + {"project_id": project_id, "user_id": user_id, "session_id": session_id} + ) + if self.row is not None: + self.row = self.row.model_copy(update={"deleted_at": None}) + return self.row + + +class _FakeTurnsService: + def __init__(self, turns: Optional[List[SessionTurn]] = None): + self.turns = turns or [] + self.query_calls: list[dict] = [] + self.delete_calls: list[dict] = [] + + async def query_turns(self, *, project_id, query=None, windowing=None): + self.query_calls.append({"project_id": project_id, "query": query}) + if query and query.references: + wanted = {r.id for r in query.references} + return [ + t + for t in self.turns + if t.references and any(r.id in wanted for r in t.references) + ] + return self.turns + + async def delete_by_session_id(self, *, project_id, session_id): + self.delete_calls.append({"project_id": project_id, "session_id": session_id}) + return len(self.turns) + + +class _FakeInteractionsService: + def __init__(self): + self.delete_calls: list[dict] = [] + + async def delete_by_session_id(self, *, project_id, session_id): + self.delete_calls.append({"project_id": project_id, "session_id": session_id}) + return 1 + + +class _FakeMountsService: + def __init__(self): + self.delete_calls: list[dict] = [] + self.archive_calls: list[dict] = [] + self.unarchive_calls: list[dict] = [] + + async def delete_session_mounts(self, *, project_id, session_id): + self.delete_calls.append({"project_id": project_id, "session_id": session_id}) + return [] + + async def archive_session_mounts(self, *, project_id, user_id, session_id): + self.archive_calls.append( + {"project_id": project_id, "user_id": user_id, "session_id": session_id} + ) + return [] + + async def unarchive_session_mounts(self, *, project_id, user_id, session_id): + self.unarchive_calls.append( + {"project_id": project_id, "user_id": user_id, "session_id": session_id} + ) + return [] + + +def _service( + *, + stream: Optional[SessionStream] = None, + turns: Optional[List[SessionTurn]] = None, +): + streams = _FakeStreamsService(row=stream) + turns_svc = _FakeTurnsService(turns=turns) + interactions = _FakeInteractionsService() + mounts = _FakeMountsService() + svc = SessionsService( + streams_service=streams, + turns_service=turns_svc, + interactions_service=interactions, + mounts_service=mounts, + ) + return svc, streams, turns_svc, interactions, mounts + + +# --------------------------------------------------------------------------- +# delete_session — F1 fan-out +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_delete_session_fans_out_to_turns_interactions_mounts_and_stream(): + svc, streams, turns_svc, interactions, mounts = _service() + + await svc.delete_session(project_id=_PROJECT, user_id=_USER, session_id=_SESSION) + + assert turns_svc.delete_calls == [{"project_id": _PROJECT, "session_id": _SESSION}] + assert interactions.delete_calls == [ + {"project_id": _PROJECT, "session_id": _SESSION} + ] + assert mounts.delete_calls == [{"project_id": _PROJECT, "session_id": _SESSION}] + assert streams.hard_delete_calls == [ + {"project_id": _PROJECT, "session_id": _SESSION} + ] + + +@pytest.mark.asyncio +async def test_delete_session_never_touches_records(): + # No records service is even injected into SessionsService -- the fan-out + # has no path to a records call. This test pins that absence structurally: + # SessionsService's constructor accepts no records_service. + import inspect + + params = inspect.signature(SessionsService.__init__).parameters + assert "records_service" not in params + + +@pytest.mark.asyncio +async def test_delete_session_keys_off_session_id_not_stream_id(): + svc, streams, turns_svc, interactions, mounts = _service() + + await svc.delete_session(project_id=_PROJECT, user_id=_USER, session_id=_SESSION) + + for call in ( + turns_svc.delete_calls[0], + interactions.delete_calls[0], + mounts.delete_calls[0], + streams.hard_delete_calls[0], + ): + assert call["session_id"] == _SESSION + assert "stream_id" not in call + + +# --------------------------------------------------------------------------- +# archive_session / unarchive_session — F2 fan-out +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_archive_session_soft_archives_stream_and_mounts(): + stream = _stream() + svc, streams, _, _, mounts = _service(stream=stream) + + result = await svc.archive_session( + project_id=_PROJECT, user_id=_USER, session_id=_SESSION + ) + + assert result is not None + assert result.deleted_at is not None + assert mounts.archive_calls == [ + {"project_id": _PROJECT, "user_id": _USER, "session_id": _SESSION} + ] + assert streams.archive_calls == [{"project_id": _PROJECT, "session_id": _SESSION}] + + +@pytest.mark.asyncio +async def test_unarchive_session_reverses_archive_round_trip(): + stream = _stream() + svc, streams, _, _, mounts = _service(stream=stream) + + archived = await svc.archive_session( + project_id=_PROJECT, user_id=_USER, session_id=_SESSION + ) + assert archived.deleted_at is not None + + unarchived = await svc.unarchive_session( + project_id=_PROJECT, user_id=_USER, session_id=_SESSION + ) + + assert unarchived is not None + assert unarchived.deleted_at is None + assert mounts.unarchive_calls == [ + {"project_id": _PROJECT, "user_id": _USER, "session_id": _SESSION} + ] + assert streams.unarchive_calls == [ + {"project_id": _PROJECT, "user_id": _USER, "session_id": _SESSION} + ] + + +# --------------------------------------------------------------------------- +# query_sessions — B3 reference join, windowed +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_query_sessions_no_filter_returns_all_streams(): + stream = _stream() + svc, streams, _, _, _ = _service(stream=stream) + + result = await svc.query_sessions(project_id=_PROJECT) + + assert result == [stream] + assert streams.query_calls[0]["session_ids"] is None + + +@pytest.mark.asyncio +async def test_query_sessions_filters_by_references_via_turns_join(): + stream = _stream() + target_ref = Reference(id=uuid4(), slug="target-workflow", version="v1") + other_ref = Reference(id=uuid4(), slug="other-workflow", version="v1") + turns = [ + _turn(_SESSION, references=[target_ref]), + _turn("some-other-session", references=[other_ref]), + ] + svc, streams, turns_svc, _, _ = _service(stream=stream, turns=turns) + + result = await svc.query_sessions( + project_id=_PROJECT, + query=SessionQuery(references=[target_ref]), + ) + + assert len(result) == 1 + assert result[0].session_id == _SESSION + # the join resolved to session_ids, not references, at the stream query boundary + assert streams.query_calls[0]["session_ids"] == [_SESSION] + + +@pytest.mark.asyncio +async def test_query_sessions_no_matching_reference_short_circuits_empty(): + stream = _stream() + unmatched_ref = Reference(id=uuid4(), slug="nope", version="v1") + svc, streams, turns_svc, _, _ = _service(stream=stream, turns=[]) + + result = await svc.query_sessions( + project_id=_PROJECT, + query=SessionQuery(references=[unmatched_ref]), + ) + + assert result == [] + # never even reaches the stream query -- no session_ids to filter by + assert streams.query_calls == [] + + +@pytest.mark.asyncio +async def test_query_sessions_passes_windowing_through(): + stream = _stream() + svc, streams, _, _, _ = _service(stream=stream) + windowing = Windowing(order="descending", limit=10) + + await svc.query_sessions(project_id=_PROJECT, windowing=windowing) + + assert streams.query_calls[0]["windowing"] is windowing diff --git a/api/oss/tests/pytest/unit/sessions/test_stream_header_merge.py b/api/oss/tests/pytest/unit/sessions/test_stream_header_merge.py new file mode 100644 index 0000000000..cb1e46b1c2 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_stream_header_merge.py @@ -0,0 +1,383 @@ +"""WP2 (S1/S8): the session header (name/description) lives on the stream row. + +Covers: + - the header edit (rename) persists through the DAO mapping and round-trips + via the DBE->DTO mapping (no live DB — mirrors test_records_mapping_upsert.py's + style); + - a heartbeat / flag-mirror write (`SessionStreamEdit`, used by heartbeat/ + detach/kill/_start_turn/_mirror_flags) never carries name/description, so it + cannot clobber a prior rename — the stated guard against write amplification; + - flags still mirror the Redis nest through a heartbeat, unaffected by the + header columns being present on the same row. +""" + +from typing import Optional +from unittest.mock import patch +from uuid import UUID, uuid4 + +import pytest +import pytest_asyncio + +from oss.src.core.sessions.streams.dtos import ( + SessionHeartbeatRequest, + SessionStream, + SessionStreamEdit, + SessionStreamHeaderEdit, +) +from oss.src.core.sessions.streams.service import SessionStreamsService +from oss.src.dbs.postgres.sessions.streams.dbes import SessionStreamDBE +from oss.src.dbs.postgres.sessions.streams.mappings import ( + map_stream_dbe_to_dto, + map_stream_dto_to_dbe_header_edit, +) + +from unit.sessions.test_project_scoped_locks import _FakeRedis + + +_PROJECT = uuid4() +_SESSION = "session_header_merge" + + +def _dbe(**over) -> SessionStreamDBE: + base = dict( + id=uuid4(), + project_id=_PROJECT, + session_id=_SESSION, + name=None, + description=None, + flags=None, + tags=None, + meta=None, + turn_id=None, + ) + base.update(over) + return SessionStreamDBE(**base) + + +# --------------------------------------------------------------------------- +# Mapping-level: the header edit only ever touches name/description. +# --------------------------------------------------------------------------- + + +def test_header_edit_sets_name_and_description(): + dbe = _dbe() + + map_stream_dto_to_dbe_header_edit( + stream_dbe=dbe, + user_id=None, + header=SessionStreamHeaderEdit(name="My Session", description="a desc"), + ) + + assert dbe.name == "My Session" + assert dbe.description == "a desc" + + +def test_header_edit_partial_field_preserves_the_other(): + # A rename PUT that sends only `name` must not null `description` (full-PUT + # semantics here means "PUT the header fields you own", not "PUT everything or + # get nulled" -- exclude_unset upstream keeps the unsent field out of the DTO). + dbe = _dbe(name="Old Name", description="Old description") + + map_stream_dto_to_dbe_header_edit( + stream_dbe=dbe, + user_id=None, + header=SessionStreamHeaderEdit(name="New Name"), + ) + + assert dbe.name == "New Name" + assert dbe.description == "Old description" + + +def test_header_edit_does_not_touch_flags_or_turn_id(): + dbe = _dbe(flags={"is_alive": True, "is_running": True}, turn_id="turn-1") + + map_stream_dto_to_dbe_header_edit( + stream_dbe=dbe, + user_id=None, + header=SessionStreamHeaderEdit(name="Renamed"), + ) + + assert dbe.flags == {"is_alive": True, "is_running": True} + assert dbe.turn_id == "turn-1" + + +def test_dbe_to_dto_round_trips_header_fields(): + dbe = _dbe(name="Round Trip", description="round trip desc") + + dto = map_stream_dbe_to_dto(stream_dbe=dbe) + + assert dto.name == "Round Trip" + assert dto.description == "round trip desc" + + +def test_flags_only_edit_never_carries_header_fields(): + # SessionStreamEdit (the DTO every flag-mirror write uses) has name/description + # fields for symmetry, but a flag-only construction must leave them unset -- + # so a heartbeat that builds SessionStreamEdit(flags=...) alone cannot clobber + # a prior rename even if some future edit accidentally forwarded model_dump(). + edit = SessionStreamEdit(flags=None) + dumped = edit.model_dump(exclude_unset=True) + assert "name" not in dumped + assert "description" not in dumped + + +# --------------------------------------------------------------------------- +# Service-level: heartbeat/flag-mirror paths never clobber name/description; +# flags still mirror the nest. +# --------------------------------------------------------------------------- + + +class _FakeStreamsDAO: + """Records every write's fields so a test can assert what a heartbeat touches. + + Mimics the real DAO's create/update contract but keeps a single in-memory row, + including name/description, so a rename set once must survive later flag-only + writes coming from the service (heartbeat/detach/kill/_mirror_flags). + """ + + def __init__(self, existing: Optional[SessionStream] = None): + self.row = existing + self.creates = 0 + self.updates = 0 + self.header_updates = 0 + + async def get_by_session_id(self, *, project_id: UUID, session_id: str): + return self.row + + async def create(self, *, project_id, user_id, stream): + self.creates += 1 + kwargs = dict( + id=uuid4(), + project_id=project_id, + session_id=stream.session_id, + name=stream.name, + description=stream.description, + turn_id=stream.turn_id, + ) + if stream.flags is not None: + kwargs["flags"] = stream.flags + self.row = SessionStream(**kwargs) + return self.row + + async def update(self, *, project_id, user_id, session_id, stream): + self.updates += 1 + # Mirror the real DAO's partial-update semantics: only overwrite a field + # when the edit actually sets it. + prior = self.row + self.row = SessionStream( + id=prior.id if prior else uuid4(), + project_id=project_id, + session_id=session_id, + name=stream.name + if stream.name is not None + else (prior.name if prior else None), + description=( + stream.description + if stream.description is not None + else (prior.description if prior else None) + ), + flags=stream.flags + if stream.flags is not None + else (prior.flags if prior else None), + turn_id=stream.turn_id + if stream.turn_id is not None + else (prior.turn_id if prior else None), + ) + return self.row + + async def update_header(self, *, project_id, user_id, session_id, header): + # Mirrors the real DAO: a select-then-update against a missing row returns + # None rather than creating one — the service's set_header falls back to + # `create` for that case. + if self.row is None: + return None + self.header_updates += 1 + prior = self.row + self.row = SessionStream( + id=prior.id, + project_id=project_id, + session_id=session_id, + name=header.name if header.name is not None else prior.name, + description=( + header.description + if header.description is not None + else prior.description + ), + turn_id=prior.turn_id, + flags=prior.flags, + ) + return self.row + + async def delete_by_session_id(self, *, project_id, session_id): + return True + + +@pytest_asyncio.fixture +async def lock_engine(): + from oss.src.dbs.redis.shared.engine import LockEngine + + eng = LockEngine() + with patch.object(eng, "_client", return_value=_FakeRedis()): + yield eng + + +def _service(lock_engine, dao): + return SessionStreamsService(streams_dao=dao, lock_engine=lock_engine) + + +def _beat(replica: str, turn: str, running: bool = True) -> SessionHeartbeatRequest: + return SessionHeartbeatRequest( + session_id=_SESSION, replica_id=replica, turn_id=turn, is_running=running + ) + + +@pytest.mark.asyncio +async def test_rename_survives_a_heartbeat_flag_mirror(lock_engine): + dao = _FakeStreamsDAO() + svc = _service(lock_engine, dao) + + # Rename first (the header edit). + await svc.set_header( + project_id=_PROJECT, + user_id=None, + session_id=_SESSION, + header=SessionStreamHeaderEdit(name="Keep Me", description="stays put"), + ) + assert dao.row.name == "Keep Me" + + # A heartbeat comes in and mirrors flags/turn_id -- it must not touch the header. + result = await svc.heartbeat( + project_id=_PROJECT, request=_beat("replica-a", "turn-a") + ) + + assert result.stream.name == "Keep Me" + assert result.stream.description == "stays put" + assert dao.row.name == "Keep Me" + assert dao.row.description == "stays put" + + +@pytest.mark.asyncio +async def test_heartbeat_still_mirrors_the_nest_flags(lock_engine): + dao = _FakeStreamsDAO() + svc = _service(lock_engine, dao) + + result = await svc.heartbeat( + project_id=_PROJECT, request=_beat("replica-a", "turn-a") + ) + + assert result.stream.flags.is_alive is True + assert result.stream.flags.is_running is True + assert dao.creates == 1 + + +@pytest.mark.asyncio +async def test_detach_flag_mirror_does_not_touch_header(lock_engine): + dao = _FakeStreamsDAO() + svc = _service(lock_engine, dao) + + await svc.set_header( + project_id=_PROJECT, + user_id=None, + session_id=_SESSION, + header=SessionStreamHeaderEdit(name="Named Before Detach"), + ) + await svc.heartbeat(project_id=_PROJECT, request=_beat("replica-a", "turn-a")) + + await svc.detach( + project_id=_PROJECT, user_id=None, session_id=_SESSION, watcher_id="w-1" + ) + + assert dao.row.name == "Named Before Detach" + + +@pytest.mark.asyncio +async def test_kill_flag_mirror_does_not_touch_header(lock_engine): + dao = _FakeStreamsDAO() + svc = _service(lock_engine, dao) + + await svc.set_header( + project_id=_PROJECT, + user_id=None, + session_id=_SESSION, + header=SessionStreamHeaderEdit(name="Named Before Kill"), + ) + await svc.heartbeat(project_id=_PROJECT, request=_beat("replica-a", "turn-a")) + + await svc.kill(project_id=_PROJECT, user_id=None, session_id=_SESSION) + + assert dao.row.name == "Named Before Kill" + + +@pytest.mark.asyncio +async def test_set_header_never_calls_the_flags_update_path(lock_engine): + dao = _FakeStreamsDAO( + existing=SessionStream( + id=uuid4(), project_id=_PROJECT, session_id=_SESSION, name="X" + ) + ) + svc = _service(lock_engine, dao) + + await svc.set_header( + project_id=_PROJECT, + user_id=None, + session_id=_SESSION, + header=SessionStreamHeaderEdit(name="Y"), + ) + + assert dao.header_updates == 1 + assert dao.updates == 0 + assert dao.creates == 0 + + +@pytest.mark.asyncio +async def test_rename_before_any_turn_creates_the_row(lock_engine): + # A caller may name a session before it has ever heartbeat/run -- update_header + # finds no row (mirrors the real DAO's select-then-None), so set_header falls + # back to create. + dao = _FakeStreamsDAO() + svc = _service(lock_engine, dao) + + stream = await svc.set_header( + project_id=_PROJECT, + user_id=None, + session_id=_SESSION, + header=SessionStreamHeaderEdit(name="Named Pre-Turn"), + ) + + assert stream.name == "Named Pre-Turn" + assert dao.creates == 1 + assert dao.header_updates == 0 + + +@pytest.mark.asyncio +async def test_rename_race_falls_back_to_header_update_on_concurrent_create( + lock_engine, +): + # A concurrent first-touch (e.g. a heartbeat) can win the create between + # set_header's update_header miss and its own create call; the DAO surfaces + # that as SessionStreamAlreadyExists, and set_header must retry as an update + # rather than raising. + from oss.src.core.sessions.streams.types import SessionStreamAlreadyExists + + class _RacyDAO(_FakeStreamsDAO): + async def create(self, *, project_id, user_id, stream): + self.row = SessionStream( + id=uuid4(), + project_id=project_id, + session_id=stream.session_id, + name=None, + ) + raise SessionStreamAlreadyExists(session_id=stream.session_id) + + dao = _RacyDAO() + svc = _service(lock_engine, dao) + + stream = await svc.set_header( + project_id=_PROJECT, + user_id=None, + session_id=_SESSION, + header=SessionStreamHeaderEdit(name="Won The Retry"), + ) + + assert stream.name == "Won The Retry" + assert dao.header_updates == 1 diff --git a/api/oss/tests/pytest/unit/sessions/test_transition_interaction_resolution.py b/api/oss/tests/pytest/unit/sessions/test_transition_interaction_resolution.py new file mode 100644 index 0000000000..f345b07ffe --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_transition_interaction_resolution.py @@ -0,0 +1,167 @@ +from unittest.mock import AsyncMock, patch +from uuid import uuid4 + +from fastapi import FastAPI, HTTPException, Request +from fastapi.testclient import TestClient +import pytest + +from oss.src.apis.fastapi.sessions.models import SessionInteractionTransitionRequest +from oss.src.apis.fastapi.sessions.router import InteractionsRouter +from oss.src.core.sessions.interactions.dtos import ( + SessionInteraction, + SessionInteractionData, + SessionInteractionKind, + SessionInteractionStatus, +) + + +def _make_authed_request(app: FastAPI, project_id, user_id) -> Request: + request = Request( + { + "type": "http", + "method": "POST", + "path": "/sessions/interactions/transition", + "headers": [], + "app": app, + } + ) + request.state.project_id = project_id + request.state.user_id = user_id + return request + + +async def test_transition_route_passes_resolution_to_the_domain_transition(): + project_id = uuid4() + user_id = uuid4() + captured = [] + + class _InteractionsService: + async def query_interactions(self, *, project_id, query): + return [ + SessionInteraction( + project_id=project_id, + session_id=query.session_id, + token="approval-token", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.pending, + ) + ] + + async def transition_interaction(self, *, transition): + captured.append(transition) + return SessionInteraction( + project_id=transition.project_id, + session_id=transition.session_id, + token=transition.token, + kind=SessionInteractionKind.user_approval, + status=transition.status, + data=SessionInteractionData(resolution=transition.resolution), + ) + + router = InteractionsRouter( + interactions_service=_InteractionsService(), + workflows_service=AsyncMock(), + respond_task=AsyncMock(), + ) + body = SessionInteractionTransitionRequest( + session_id="session-1", + token="approval-token", + status=SessionInteractionStatus.resolved, + resolution={"verdict": "denied", "tool_call_id": "tool-1"}, + ) + + with patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=True, + ): + response = await router.transition_interaction( + request=_make_authed_request(FastAPI(), project_id, user_id), + body=body, + ) + + assert len(captured) == 1 + assert captured[0].resolution == { + "verdict": "denied", + "tool_call_id": "tool-1", + } + assert response.interaction is not None + assert response.interaction.data is not None + assert response.interaction.data.resolution == captured[0].resolution + + +@pytest.mark.parametrize( + "payload", + [ + { + "session_id": "session-1", + "token": "approval-token", + "status": "pending", + "resolution": {"verdict": "approved", "tool_call_id": "tool-1"}, + }, + { + "session_id": "session-1", + "token": "approval-token", + "status": "resolved", + "resolution": {"verdict": "maybe", "tool_call_id": "tool-1"}, + }, + { + "session_id": "session-1", + "token": "approval-token", + "status": "resolved", + "resolution": {"verdict": "approved"}, + }, + ], +) +def test_transition_route_rejects_invalid_approval_resolution_with_422(payload): + router = InteractionsRouter( + interactions_service=AsyncMock(), + workflows_service=AsyncMock(), + respond_task=AsyncMock(), + ) + app = FastAPI() + app.include_router(router.router) + + response = TestClient(app).post("/transition", json=payload) + + assert response.status_code == 422 + + +async def test_transition_route_rejects_resolution_for_client_tool_with_409(): + project_id = uuid4() + user_id = uuid4() + interactions_service = AsyncMock() + interactions_service.query_interactions.return_value = [ + SessionInteraction( + project_id=project_id, + session_id="session-1", + token="client-tool-token", + kind=SessionInteractionKind.client_tool, + status=SessionInteractionStatus.pending, + ) + ] + router = InteractionsRouter( + interactions_service=interactions_service, + workflows_service=AsyncMock(), + respond_task=AsyncMock(), + ) + body = SessionInteractionTransitionRequest( + session_id="session-1", + token="client-tool-token", + status=SessionInteractionStatus.resolved, + resolution={"verdict": "approved", "tool_call_id": "tool-1"}, + ) + + with patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=True, + ): + with pytest.raises(HTTPException) as caught: + await router.transition_interaction( + request=_make_authed_request(FastAPI(), project_id, user_id), + body=body, + ) + + assert caught.value.status_code == 409 + interactions_service.transition_interaction.assert_not_awaited() diff --git a/api/oss/tests/pytest/unit/sessions/test_turns_complete.py b/api/oss/tests/pytest/unit/sessions/test_turns_complete.py new file mode 100644 index 0000000000..4de08dc956 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_turns_complete.py @@ -0,0 +1,116 @@ +from datetime import datetime, timezone +from unittest.mock import AsyncMock, patch +from uuid import uuid4 + +import pytest +from fastapi import FastAPI, HTTPException, Request + +from oss.src.apis.fastapi.sessions.models import SessionTurnCompleteRequest +from oss.src.apis.fastapi.sessions.router import SessionTurnsRouter +from oss.src.core.sessions.turns.dtos import HarnessKind, SessionTurn +from oss.src.core.sessions.turns.types import SessionTurnNotFound + + +def _request(project_id, user_id) -> Request: + scope = { + "type": "http", + "method": "POST", + "path": "/sessions/turns/complete", + "headers": [], + "app": FastAPI(), + } + request = Request(scope) + request.state.project_id = str(project_id) + request.state.user_id = str(user_id) + return request + + +def _access(allowed: bool): + return patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=allowed, + ) + + +async def test_complete_turn_delegates_project_scoped_key_and_completion_fields(): + project_id = uuid4() + user_id = uuid4() + stream_id = uuid4() + ended_at = datetime.now(timezone.utc) + completed = SessionTurn( + id=uuid4(), + project_id=project_id, + session_id="session-1", + stream_id=stream_id, + turn_index=4, + harness_kind=HarnessKind.CLAUDE, + agent_session_id="agent-1", + end_time=ended_at, + ) + service = AsyncMock() + service.complete_turn.return_value = completed + router = SessionTurnsRouter(turns_service=service) + + with _access(True): + response = await router.complete_turn( + request=_request(project_id, user_id), + body=SessionTurnCompleteRequest( + session_id="session-1", + turn_index=4, + agent_session_id="agent-1", + end_time=ended_at, + ), + ) + + assert response.count == 1 + assert response.turn == completed + call = service.complete_turn.await_args.kwargs + assert call["project_id"] == str(project_id) + assert call["turn"].session_id == "session-1" + assert call["turn"].turn_index == 4 + assert call["turn"].agent_session_id == "agent-1" + assert call["turn"].end_time == ended_at + + +async def test_complete_turn_refuses_unknown_row_with_404(): + project_id = uuid4() + user_id = uuid4() + service = AsyncMock() + service.complete_turn.side_effect = SessionTurnNotFound("missing-session", 7) + router = SessionTurnsRouter(turns_service=service) + + with _access(True): + with pytest.raises(HTTPException) as exc_info: + await router.complete_turn( + request=_request(project_id, user_id), + body=SessionTurnCompleteRequest( + session_id="missing-session", + turn_index=7, + end_time=datetime.now(timezone.utc), + ), + ) + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "No turn 7 found for session 'missing-session'." + + +async def test_complete_turn_rejects_without_run_permission(): + project_id = uuid4() + user_id = uuid4() + service = AsyncMock() + router = SessionTurnsRouter(turns_service=service) + + with _access(False): + with pytest.raises(HTTPException) as exc_info: + await router.complete_turn( + request=_request(project_id, user_id), + body=SessionTurnCompleteRequest( + session_id="session-1", + turn_index=0, + end_time=datetime.now(timezone.utc), + ), + ) + + assert exc_info.value.status_code == 403 + service.complete_turn.assert_not_awaited() diff --git a/api/oss/tests/pytest/unit/sessions/test_turns_dao.py b/api/oss/tests/pytest/unit/sessions/test_turns_dao.py new file mode 100644 index 0000000000..06b4592aeb --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_turns_dao.py @@ -0,0 +1,560 @@ +"""Integration-style tests for SessionTurnsDAO against a real Postgres. + +Requires the core_oss migration chain applied and POSTGRES_URI_CORE pointed at that +database. Exercises: append, references GIN `.contains()` filtering, windowed +newest->oldest listing, latest_turn / latest_turn_per_harness resolution, and +hard-delete-by-session — the behaviors a mock cannot faithfully stand in for. +""" + +import uuid +from datetime import datetime, timezone + +import pytest +from sqlalchemy import text + +from oss.src.core.sessions.turns.dtos import ( + HarnessKind, + SessionTurnComplete, + SessionTurnCreate, + SessionTurnQuery, +) +from oss.src.core.sessions.turns.service import SessionTurnsService +from oss.src.core.sessions.turns.types import SessionTurnNotFound +from oss.src.core.shared.dtos import Reference, Windowing +import oss.src.models.db_models # noqa: F401 +from oss.src.dbs.postgres.sessions.streams.dbes import SessionStreamDBE # noqa: F401 +from oss.src.dbs.postgres.sessions.turns.dao import SessionTurnsDAO +import oss.src.dbs.postgres.shared.engine as engine_module +from oss.src.dbs.postgres.shared.engine import get_transactions_engine + + +pytestmark = pytest.mark.integration + + +@pytest.fixture(autouse=True) +async def _fresh_engine_per_test(): + """Each pytest-asyncio test gets its own event loop; the module-level engine + singleton binds its asyncpg pool to the first loop that touches it. Reset it + so every test starts with a pool bound to its own loop.""" + engine_module._transactions_engine = None + yield + if engine_module._transactions_engine is not None: + await engine_module._transactions_engine.close() + engine_module._transactions_engine = None + + +@pytest.fixture +async def project_and_stream(): + """Provision the minimal FK chain: user -> org -> workspace -> project -> stream.""" + engine = get_transactions_engine() + + user_id = uuid.uuid4() + org_id = uuid.uuid4() + workspace_id = uuid.uuid4() + project_id = uuid.uuid4() + stream_id = uuid.uuid4() + session_id = f"turns-dao-test-{uuid.uuid4().hex[:8]}" + + async with engine.session() as session: + await session.execute( + text( + "INSERT INTO users (id, uid, username, email) " + "VALUES (:id, :uid, :username, :email)" + ), + { + "id": user_id, + "uid": str(user_id), + "username": "turns-dao-test", + "email": f"turns-dao-test-{user_id.hex[:8]}@example.com", + }, + ) + await session.execute( + text( + "INSERT INTO organizations (id, name, owner_id) " + "VALUES (:id, :name, :owner_id)" + ), + {"id": org_id, "name": "turns-dao-test-org", "owner_id": user_id}, + ) + await session.execute( + text( + "INSERT INTO workspaces (id, name, organization_id) " + "VALUES (:id, :name, :organization_id)" + ), + { + "id": workspace_id, + "name": "turns-dao-test-ws", + "organization_id": org_id, + }, + ) + await session.execute( + text( + "INSERT INTO projects (id, project_name, workspace_id, organization_id) " + "VALUES (:id, :project_name, :workspace_id, :organization_id)" + ), + { + "id": project_id, + "project_name": "turns-dao-test-project", + "workspace_id": workspace_id, + "organization_id": org_id, + }, + ) + await session.execute( + text( + "INSERT INTO session_streams (id, project_id, session_id) " + "VALUES (:id, :project_id, :session_id)" + ), + {"id": stream_id, "project_id": project_id, "session_id": session_id}, + ) + await session.commit() + + yield { + "project_id": project_id, + "stream_id": stream_id, + "session_id": session_id, + "user_id": user_id, + } + + async with engine.session() as session: + await session.execute( + text("DELETE FROM session_turns WHERE project_id = :project_id"), + {"project_id": project_id}, + ) + await session.execute( + text("DELETE FROM session_streams WHERE project_id = :project_id"), + {"project_id": project_id}, + ) + await session.execute( + text("DELETE FROM projects WHERE id = :id"), {"id": project_id} + ) + await session.execute( + text("DELETE FROM workspaces WHERE id = :id"), {"id": workspace_id} + ) + await session.execute( + text("DELETE FROM organizations WHERE id = :id"), {"id": org_id} + ) + await session.execute(text("DELETE FROM users WHERE id = :id"), {"id": user_id}) + await session.commit() + + +@pytest.fixture +def dao(): + return SessionTurnsDAO(engine=get_transactions_engine()) + + +async def test_append_turn_persists_and_query_returns_turn_id(dao, project_and_stream): + """W1.6: append a turn — round-trips every field, and created_by_id is the caller.""" + project_id = project_and_stream["project_id"] + stream_id = project_and_stream["stream_id"] + session_id = project_and_stream["session_id"] + user_id = project_and_stream["user_id"] + + workflow_ref = Reference(id=uuid.uuid4(), slug="my-workflow", version="v1") + + # span_id is a 16-hex OTel span id (runner shape), NOT a UUID; trace_id is a 32-hex + # OTel trace id that still fits UUID. + span_id = uuid.uuid4().hex[:16] + execution_turn_id = uuid.uuid4() + turn = await dao.append( + project_id=project_id, + user_id=user_id, + turn=SessionTurnCreate( + session_id=session_id, + turn_id=execution_turn_id, + stream_id=stream_id, + turn_index=0, + harness_kind=HarnessKind.PI, + agent_session_id="agent-sess-abc", + sandbox_id="sandbox-1", + references=[workflow_ref], + trace_id=uuid.uuid4(), + span_id=span_id, + start_time=datetime.now(timezone.utc), + ), + ) + + assert turn.id is not None + assert turn.session_id == session_id + assert turn.turn_id == execution_turn_id + assert turn.stream_id == stream_id + assert turn.turn_index == 0 + assert turn.harness_kind == HarnessKind.PI + assert turn.agent_session_id == "agent-sess-abc" + assert turn.sandbox_id == "sandbox-1" + assert turn.references == [workflow_ref] + assert turn.span_id == span_id + # jp's requirement: append_turn must populate created_by_id from the caller. + assert turn.created_by_id == user_id + + fetched = await dao.fetch_turn(project_id=project_id, turn_id=turn.id) + assert fetched is not None + assert fetched.id == turn.id + assert fetched.turn_id == execution_turn_id + assert fetched.references == [workflow_ref] + assert fetched.span_id == span_id + + queried = await dao.query_turns( + project_id=project_id, + query=SessionTurnQuery(session_id=session_id), + ) + assert len(queried) == 1 + assert queried[0].turn_id == execution_turn_id + + +async def test_complete_turn_is_guarded_idempotent_and_refuses_unknown( + dao, project_and_stream +): + project_id = project_and_stream["project_id"] + stream_id = project_and_stream["stream_id"] + session_id = project_and_stream["session_id"] + started_at = datetime.now(timezone.utc) + workflow_ref = Reference(id=uuid.uuid4(), slug="workflow", version="v1") + + started = await dao.append( + project_id=project_id, + user_id=None, + turn=SessionTurnCreate( + session_id=session_id, + stream_id=stream_id, + turn_index=0, + harness_kind=HarnessKind.CLAUDE, + sandbox_id="sandbox-start", + references=[workflow_ref], + trace_id=uuid.uuid4(), + span_id=uuid.uuid4().hex[:16], + start_time=started_at, + ), + ) + service = SessionTurnsService(turns_dao=dao) + completed_at = datetime.now(timezone.utc) + + for guarded_project_id, guarded_session_id, guarded_turn_index in ( + (uuid.uuid4(), session_id, 0), + (project_id, f"{session_id}-unknown", 0), + (project_id, session_id, 99), + ): + with pytest.raises(SessionTurnNotFound): + await service.complete_turn( + project_id=guarded_project_id, + turn=SessionTurnComplete( + session_id=guarded_session_id, + turn_index=guarded_turn_index, + agent_session_id="must-not-land", + end_time=completed_at, + ), + ) + + completed = await service.complete_turn( + project_id=project_id, + turn=SessionTurnComplete( + session_id=session_id, + turn_index=0, + agent_session_id="agent-complete", + end_time=completed_at, + ), + ) + assert completed.id == started.id + assert completed.end_time == completed_at + assert completed.agent_session_id == "agent-complete" + assert completed.stream_id == started.stream_id + assert completed.harness_kind == started.harness_kind + assert completed.sandbox_id == started.sandbox_id + assert completed.references == started.references + assert completed.trace_id == started.trace_id + assert completed.span_id == started.span_id + assert completed.start_time == started.start_time + + retried = await service.complete_turn( + project_id=project_id, + turn=SessionTurnComplete( + session_id=session_id, + turn_index=0, + agent_session_id="agent-must-not-replace-first-completion", + end_time=datetime.now(timezone.utc), + ), + ) + assert retried.end_time == completed_at + assert retried.agent_session_id == "agent-complete" + + +async def test_multi_turn_appends_persist_with_incrementing_index_and_stable_stream( + dao, project_and_stream +): + """Two turns on one session persist as two rows with incrementing turn_index and the + same stream_id (the session_streams row id) — the durable-turns invariant a live + two-turn run relies on. Covers the E2E gap that no test asserted (TEST-GAPS.md §turns).""" + project_id = project_and_stream["project_id"] + stream_id = project_and_stream["stream_id"] + session_id = project_and_stream["session_id"] + + for turn_index in (0, 1): + await dao.append( + project_id=project_id, + user_id=None, + turn=SessionTurnCreate( + session_id=session_id, + stream_id=stream_id, + turn_index=turn_index, + harness_kind=HarnessKind.PI, + ), + ) + + results = await dao.query_turns( + project_id=project_id, + query=SessionTurnQuery(session_id=session_id), + ) + + assert len(results) == 2 + assert {t.turn_index for t in results} == {0, 1} + # Every turn in a session shares the one session_streams row id. + assert {t.stream_id for t in results} == {stream_id} + + +async def test_query_turns_filters_by_references_gin_contains(dao, project_and_stream): + """W1.6: query by references uses the eval_runs GIN `.contains()` pattern.""" + project_id = project_and_stream["project_id"] + stream_id = project_and_stream["stream_id"] + session_id = project_and_stream["session_id"] + "-refs" + + async with get_transactions_engine().session() as session: + await session.execute( + text( + "INSERT INTO session_streams (id, project_id, session_id) " + "VALUES (:id, :project_id, :session_id)" + ), + {"id": uuid.uuid4(), "project_id": project_id, "session_id": session_id}, + ) + await session.commit() + + target_ref = Reference(id=uuid.uuid4(), slug="target-workflow", version="v1") + other_ref = Reference(id=uuid.uuid4(), slug="other-workflow", version="v1") + + matching = await dao.append( + project_id=project_id, + user_id=None, + turn=SessionTurnCreate( + session_id=session_id, + stream_id=stream_id, + turn_index=0, + harness_kind=HarnessKind.PI, + references=[target_ref], + ), + ) + await dao.append( + project_id=project_id, + user_id=None, + turn=SessionTurnCreate( + session_id=session_id, + stream_id=stream_id, + turn_index=1, + harness_kind=HarnessKind.PI, + references=[other_ref], + ), + ) + + results = await dao.query_turns( + project_id=project_id, + query=SessionTurnQuery(session_id=session_id, references=[target_ref]), + ) + + assert len(results) == 1 + assert results[0].id == matching.id + + +async def test_query_turns_orders_by_turn_index_with_id_tiebreaker( + dao, project_and_stream +): + """The list follows semantic turn order, independent of insertion order.""" + project_id = project_and_stream["project_id"] + stream_id = project_and_stream["stream_id"] + session_id = project_and_stream["session_id"] + "-window" + + async with get_transactions_engine().session() as session: + await session.execute( + text( + "INSERT INTO session_streams (id, project_id, session_id) " + "VALUES (:id, :project_id, :session_id)" + ), + {"id": uuid.uuid4(), "project_id": project_id, "session_id": session_id}, + ) + await session.commit() + + created_ids_by_index = {} + for i in (2, 0, 1): + turn = await dao.append( + project_id=project_id, + user_id=None, + turn=SessionTurnCreate( + session_id=session_id, + stream_id=stream_id, + turn_index=i, + harness_kind=HarnessKind.PI, + ), + ) + created_ids_by_index[i] = turn.id + + results = await dao.query_turns( + project_id=project_id, + query=SessionTurnQuery(session_id=session_id), + windowing=Windowing(order="descending"), + ) + + assert [r.turn_index for r in results] == [2, 1, 0] + assert [r.id for r in results] == [ + created_ids_by_index[2], + created_ids_by_index[1], + created_ids_by_index[0], + ] + + next_page = await dao.query_turns( + project_id=project_id, + query=SessionTurnQuery(session_id=session_id), + windowing=Windowing(order="descending", next=created_ids_by_index[1]), + ) + assert [r.turn_index for r in next_page] == [0] + + tie_reference = Reference(id=uuid.uuid4(), slug="ordering-tie") + tied_turns = [] + for tied_session_id in (f"{session_id}-tie-a", f"{session_id}-tie-b"): + tied_turns.append( + await dao.append( + project_id=project_id, + user_id=None, + turn=SessionTurnCreate( + session_id=tied_session_id, + stream_id=stream_id, + turn_index=1, + harness_kind=HarnessKind.PI, + references=[tie_reference], + ), + ) + ) + + tied_results = await dao.query_turns( + project_id=project_id, + query=SessionTurnQuery(references=[tie_reference]), + windowing=Windowing(order="descending"), + ) + assert [r.id for r in tied_results] == sorted( + [turn.id for turn in tied_turns], reverse=True + ) + + +async def test_latest_turn_and_latest_turn_per_harness(dao, project_and_stream): + """W1.6: latest_turn / latest_turn_per_harness — the runner's resume-read (WP3).""" + project_id = project_and_stream["project_id"] + stream_id = project_and_stream["stream_id"] + session_id = project_and_stream["session_id"] + "-latest" + + async with get_transactions_engine().session() as session: + await session.execute( + text( + "INSERT INTO session_streams (id, project_id, session_id) " + "VALUES (:id, :project_id, :session_id)" + ), + {"id": uuid.uuid4(), "project_id": project_id, "session_id": session_id}, + ) + await session.commit() + + await dao.append( + project_id=project_id, + user_id=None, + turn=SessionTurnCreate( + session_id=session_id, + stream_id=stream_id, + turn_index=0, + harness_kind=HarnessKind.PI, + agent_session_id="pi-core-sess-0", + sandbox_id="sandbox-0", + ), + ) + await dao.append( + project_id=project_id, + user_id=None, + turn=SessionTurnCreate( + session_id=session_id, + stream_id=stream_id, + turn_index=1, + harness_kind=HarnessKind.CLAUDE, + agent_session_id="claude-sess-1", + sandbox_id="sandbox-1", + ), + ) + latest_pi_core = await dao.append( + project_id=project_id, + user_id=None, + turn=SessionTurnCreate( + session_id=session_id, + stream_id=stream_id, + turn_index=2, + harness_kind=HarnessKind.PI, + agent_session_id="pi-core-sess-2", + sandbox_id="sandbox-2", + ), + ) + + # A late-arriving lower-index write for a stale turn must never win — the + # resume pointer is ORDER BY turn_index DESC, not insertion order. + latest_overall = await dao.append( + project_id=project_id, + user_id=None, + turn=SessionTurnCreate( + session_id=session_id, + stream_id=stream_id, + turn_index=3, + harness_kind=HarnessKind.CLAUDE, + agent_session_id="claude-sess-3", + sandbox_id="sandbox-3", + ), + ) + + latest = await dao.latest_turn(project_id=project_id, session_id=session_id) + assert latest is not None + assert latest.id == latest_overall.id + assert latest.turn_index == 3 + assert latest.sandbox_id == "sandbox-3" + + latest_for_pi_core = await dao.latest_turn_per_harness_kind( + project_id=project_id, session_id=session_id, harness_kind=HarnessKind.PI + ) + assert latest_for_pi_core is not None + assert latest_for_pi_core.id == latest_pi_core.id + assert latest_for_pi_core.agent_session_id == "pi-core-sess-2" + + +async def test_delete_by_session_id_hard_deletes(dao, project_and_stream): + """W1.6: hard-delete-by-session (WP5's fan-out).""" + project_id = project_and_stream["project_id"] + stream_id = project_and_stream["stream_id"] + session_id = project_and_stream["session_id"] + "-delete" + + async with get_transactions_engine().session() as session: + await session.execute( + text( + "INSERT INTO session_streams (id, project_id, session_id) " + "VALUES (:id, :project_id, :session_id)" + ), + {"id": uuid.uuid4(), "project_id": project_id, "session_id": session_id}, + ) + await session.commit() + + for i in range(2): + await dao.append( + project_id=project_id, + user_id=None, + turn=SessionTurnCreate( + session_id=session_id, + stream_id=stream_id, + turn_index=i, + harness_kind=HarnessKind.PI, + ), + ) + + deleted_count = await dao.delete_by_session_id( + project_id=project_id, session_id=session_id + ) + assert deleted_count == 2 + + remaining = await dao.query_turns( + project_id=project_id, query=SessionTurnQuery(session_id=session_id) + ) + assert remaining == [] diff --git a/api/oss/tests/pytest/unit/sessions/test_turns_dao_conflict.py b/api/oss/tests/pytest/unit/sessions/test_turns_dao_conflict.py new file mode 100644 index 0000000000..2a266f1d28 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_turns_dao_conflict.py @@ -0,0 +1,77 @@ +"""A duplicate session turn maps to HTTP 409 instead of an uncaught database error.""" + +from contextlib import asynccontextmanager +from uuid import uuid4 + +import pytest +from sqlalchemy.exc import IntegrityError + +from oss.src.core.sessions.turns.dtos import HarnessKind, SessionTurnCreate +from oss.src.dbs.postgres.sessions.turns.dao import SessionTurnsDAO +from oss.src.utils.exceptions import ConflictException, intercept_exceptions + + +class _FakeSession: + def __init__(self): + self.rollback_count = 0 + + def add(self, _dbe): + pass + + async def commit(self): + raise IntegrityError( + "INSERT INTO session_turns ...", + {}, + Exception( + "duplicate key value violates unique constraint " + '"ix_session_turns_project_id_session_id_turn_index"' + ), + ) + + async def refresh(self, _dbe): + raise AssertionError("a failed insert must not refresh") + + async def rollback(self): + self.rollback_count += 1 + + +class _FakeEngine: + def __init__(self): + self.session_handle = _FakeSession() + + @asynccontextmanager + async def session(self): + yield self.session_handle + + +@pytest.mark.anyio +async def test_duplicate_turn_returns_409_and_rolls_back(anyio_backend): + assert anyio_backend == "asyncio" + engine = _FakeEngine() + dao = SessionTurnsDAO(engine=engine) + session_id = "sess-duplicate-1" + turn = SessionTurnCreate( + session_id=session_id, + stream_id=uuid4(), + turn_index=3, + harness_kind=HarnessKind.PI, + ) + + @intercept_exceptions(verbose=False) + async def append_turn(): + return await dao.append(project_id=uuid4(), user_id=None, turn=turn) + + with pytest.raises(ConflictException) as exc_info: + await append_turn() + + assert engine.session_handle.rollback_count == 1 + assert exc_info.value.status_code == 409 + assert exc_info.value.detail == { + "message": f"Session turn 3 already exists for session {session_id}.", + "conflict": {"session_id": session_id, "turn_index": 3}, + } + + +@pytest.fixture +def anyio_backend(): + return "asyncio" diff --git a/api/oss/tests/pytest/unit/sessions/test_wp5_dao_fanout.py b/api/oss/tests/pytest/unit/sessions/test_wp5_dao_fanout.py new file mode 100644 index 0000000000..d867d1eef2 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_wp5_dao_fanout.py @@ -0,0 +1,491 @@ +"""Integration-style tests for the new WP5 DAO plumbing against a real Postgres. + +Requires the core_oss migration chain applied and POSTGRES_URI_CORE pointed at that +database (same fixture shape as test_turns_dao.py). Exercises the DAO methods the +delete/archive/unarchive fan-out is built on, and were "new plumbing" per the brief +(everything else was soft-delete-only before this WP): + + - SessionInteractionsDAO.delete_by_session_id — hard delete (was soft-only via + cancel_session_pending's status flip). + - SessionStreamsDAO.hard_delete_by_session_id — hard delete (kill only soft- + deletes via delete_by_session_id). + - SessionStreamsDAO.unarchive_by_session_id / get_by_session_id_including_archived + — the archive round-trip's reverse + confirmation read. + - MountsDAO.delete_by_session_id — hard delete of session-bound mount rows, + returning the deleted rows (so the service can tear down their prefixes). +""" + +import uuid + +import pytest +from sqlalchemy import text + +from oss.src.core.sessions.interactions.dtos import ( + SessionInteractionCreate, + SessionInteractionData, + SessionInteractionKind, + SessionInteractionQuery, + SessionInteractionStatus, + SessionInteractionTransition, +) +from oss.src.core.mounts.dtos import MountCreate +import oss.src.models.db_models # noqa: F401 +from oss.src.dbs.postgres.sessions.streams.dbes import SessionStreamDBE # noqa: F401 +from oss.src.dbs.postgres.sessions.streams.dao import SessionStreamsDAO +from oss.src.dbs.postgres.sessions.interactions.dao import SessionInteractionsDAO +from oss.src.dbs.postgres.mounts.dao import MountsDAO +import oss.src.dbs.postgres.shared.engine as engine_module +from oss.src.dbs.postgres.shared.engine import get_transactions_engine + + +pytestmark = pytest.mark.integration + + +@pytest.fixture(autouse=True) +async def _fresh_engine_per_test(): + engine_module._transactions_engine = None + yield + if engine_module._transactions_engine is not None: + await engine_module._transactions_engine.close() + engine_module._transactions_engine = None + + +@pytest.fixture +async def project(): + """Provision the minimal FK chain: user -> org -> workspace -> project.""" + engine = get_transactions_engine() + + user_id = uuid.uuid4() + org_id = uuid.uuid4() + workspace_id = uuid.uuid4() + project_id = uuid.uuid4() + + async with engine.session() as session: + await session.execute( + text( + "INSERT INTO users (id, uid, username, email) " + "VALUES (:id, :uid, :username, :email)" + ), + { + "id": user_id, + "uid": str(user_id), + "username": "wp5-fanout-test", + "email": f"wp5-fanout-test-{user_id.hex[:8]}@example.com", + }, + ) + await session.execute( + text( + "INSERT INTO organizations (id, name, owner_id) " + "VALUES (:id, :name, :owner_id)" + ), + {"id": org_id, "name": "wp5-fanout-test-org", "owner_id": user_id}, + ) + await session.execute( + text( + "INSERT INTO workspaces (id, name, organization_id) " + "VALUES (:id, :name, :organization_id)" + ), + { + "id": workspace_id, + "name": "wp5-fanout-test-ws", + "organization_id": org_id, + }, + ) + await session.execute( + text( + "INSERT INTO projects (id, project_name, workspace_id, organization_id) " + "VALUES (:id, :project_name, :workspace_id, :organization_id)" + ), + { + "id": project_id, + "project_name": "wp5-fanout-test-project", + "workspace_id": workspace_id, + "organization_id": org_id, + }, + ) + await session.commit() + + yield {"project_id": project_id, "user_id": user_id} + + async with engine.session() as session: + for table in ( + "mounts", + "session_interactions", + "session_turns", + "session_streams", + ): + await session.execute( + text(f"DELETE FROM {table} WHERE project_id = :project_id"), + {"project_id": project_id}, + ) + await session.execute( + text("DELETE FROM projects WHERE id = :id"), {"id": project_id} + ) + await session.execute( + text("DELETE FROM workspaces WHERE id = :id"), {"id": workspace_id} + ) + await session.execute( + text("DELETE FROM organizations WHERE id = :id"), {"id": org_id} + ) + await session.execute(text("DELETE FROM users WHERE id = :id"), {"id": user_id}) + await session.commit() + + +@pytest.fixture +def streams_dao(): + return SessionStreamsDAO(engine=get_transactions_engine()) + + +@pytest.fixture +def interactions_dao(): + return SessionInteractionsDAO(engine=get_transactions_engine()) + + +@pytest.fixture +def mounts_dao(): + return MountsDAO(engine=get_transactions_engine()) + + +# --------------------------------------------------------------------------- +# SessionInteractionsDAO transition resolution +# --------------------------------------------------------------------------- + + +async def test_interaction_transition_preserves_data_and_optionally_adds_resolution( + interactions_dao, project +): + project_id = project["project_id"] + session_id = f"interaction-resolution-{uuid.uuid4().hex[:8]}" + request = {"tool": "bash", "args": {"command": "ls"}} + + await interactions_dao.create_interaction( + project_id=project_id, + user_id=None, + interaction=SessionInteractionCreate( + project_id=project_id, + session_id=session_id, + token="approval-token", + kind=SessionInteractionKind.user_approval, + data=SessionInteractionData(request=request), + ), + ) + transitioned = await interactions_dao.transition_interaction( + transition=SessionInteractionTransition( + project_id=project_id, + session_id=session_id, + token="approval-token", + status=SessionInteractionStatus.resolved, + resolution={"verdict": "approved", "tool_call_id": "tool-1"}, + ) + ) + + assert transitioned is not None + assert transitioned.status == SessionInteractionStatus.resolved + assert transitioned.data is not None + assert transitioned.data.request == request + assert transitioned.data.resolution == { + "verdict": "approved", + "tool_call_id": "tool-1", + } + + await interactions_dao.create_interaction( + project_id=project_id, + user_id=None, + interaction=SessionInteractionCreate( + project_id=project_id, + session_id=session_id, + token="client-token", + kind=SessionInteractionKind.client_tool, + data=SessionInteractionData(request=request), + ), + ) + transitioned_without_resolution = await interactions_dao.transition_interaction( + transition=SessionInteractionTransition( + project_id=project_id, + session_id=session_id, + token="client-token", + status=SessionInteractionStatus.resolved, + ) + ) + + assert transitioned_without_resolution is not None + assert transitioned_without_resolution.data is not None + assert transitioned_without_resolution.data.request == request + assert transitioned_without_resolution.data.resolution is None + + +# --------------------------------------------------------------------------- +# SessionInteractionsDAO.delete_by_session_id — new hard delete +# --------------------------------------------------------------------------- + + +async def test_interactions_delete_by_session_id_hard_deletes( + interactions_dao, project +): + project_id = project["project_id"] + session_id = f"wp5-interactions-{uuid.uuid4().hex[:8]}" + + for i in range(2): + await interactions_dao.create_interaction( + project_id=project_id, + user_id=None, + interaction=SessionInteractionCreate( + project_id=project_id, + session_id=session_id, + token=f"token-{i}", + kind=SessionInteractionKind.user_approval, + ), + ) + + deleted_count = await interactions_dao.delete_by_session_id( + project_id=project_id, session_id=session_id + ) + assert deleted_count == 2 + + remaining = await interactions_dao.query_interactions( + project_id=project_id, + query=SessionInteractionQuery(session_id=session_id), + ) + assert remaining == [] + + +async def test_interactions_delete_by_session_id_scoped_to_session( + interactions_dao, project +): + """Deleting one session's interactions must not touch another session's rows.""" + project_id = project["project_id"] + session_a = f"wp5-interactions-a-{uuid.uuid4().hex[:8]}" + session_b = f"wp5-interactions-b-{uuid.uuid4().hex[:8]}" + + await interactions_dao.create_interaction( + project_id=project_id, + user_id=None, + interaction=SessionInteractionCreate( + project_id=project_id, + session_id=session_a, + token="token-a", + kind=SessionInteractionKind.user_approval, + ), + ) + await interactions_dao.create_interaction( + project_id=project_id, + user_id=None, + interaction=SessionInteractionCreate( + project_id=project_id, + session_id=session_b, + token="token-b", + kind=SessionInteractionKind.user_approval, + ), + ) + + deleted_count = await interactions_dao.delete_by_session_id( + project_id=project_id, session_id=session_a + ) + assert deleted_count == 1 + + remaining_b = await interactions_dao.query_interactions( + project_id=project_id, + query=SessionInteractionQuery(session_id=session_b), + ) + assert len(remaining_b) == 1 + + +# --------------------------------------------------------------------------- +# SessionStreamsDAO.hard_delete_by_session_id — new hard delete +# --------------------------------------------------------------------------- + + +async def test_streams_hard_delete_by_session_id(streams_dao, project): + project_id = project["project_id"] + session_id = f"wp5-streams-hard-{uuid.uuid4().hex[:8]}" + + async with get_transactions_engine().session() as session: + await session.execute( + text( + "INSERT INTO session_streams (id, project_id, session_id) " + "VALUES (:id, :project_id, :session_id)" + ), + {"id": uuid.uuid4(), "project_id": project_id, "session_id": session_id}, + ) + await session.commit() + + deleted = await streams_dao.hard_delete_by_session_id( + project_id=project_id, session_id=session_id + ) + assert deleted is True + + # Hard-deleted: not even visible to the archived-inclusive read. + row = await streams_dao.get_by_session_id_including_archived( + project_id=project_id, session_id=session_id + ) + assert row is None + + +async def test_streams_hard_delete_is_distinct_from_soft_kill_delete( + streams_dao, project +): + """kill's delete_by_session_id (soft) leaves the row queryable with deleted_at + set; hard_delete_by_session_id actually removes it. Same session, two paths.""" + project_id = project["project_id"] + session_id = f"wp5-streams-soft-vs-hard-{uuid.uuid4().hex[:8]}" + + async with get_transactions_engine().session() as session: + await session.execute( + text( + "INSERT INTO session_streams (id, project_id, session_id) " + "VALUES (:id, :project_id, :session_id)" + ), + {"id": uuid.uuid4(), "project_id": project_id, "session_id": session_id}, + ) + await session.commit() + + soft_deleted = await streams_dao.delete_by_session_id( + project_id=project_id, session_id=session_id + ) + assert soft_deleted is True + + still_there = await streams_dao.get_by_session_id_including_archived( + project_id=project_id, session_id=session_id + ) + assert still_there is not None + assert still_there.deleted_at is not None + + hard_deleted = await streams_dao.hard_delete_by_session_id( + project_id=project_id, session_id=session_id + ) + assert hard_deleted is True + + gone = await streams_dao.get_by_session_id_including_archived( + project_id=project_id, session_id=session_id + ) + assert gone is None + + +# --------------------------------------------------------------------------- +# SessionStreamsDAO archive/unarchive round trip +# --------------------------------------------------------------------------- + + +async def test_streams_archive_unarchive_round_trip(streams_dao, project): + project_id = project["project_id"] + session_id = f"wp5-streams-archive-{uuid.uuid4().hex[:8]}" + + async with get_transactions_engine().session() as session: + await session.execute( + text( + "INSERT INTO session_streams (id, project_id, session_id) " + "VALUES (:id, :project_id, :session_id)" + ), + {"id": uuid.uuid4(), "project_id": project_id, "session_id": session_id}, + ) + await session.commit() + + archived = await streams_dao.delete_by_session_id( + project_id=project_id, session_id=session_id + ) + assert archived is True + + # Not visible via the normal (non-archived) read. + normal_read = await streams_dao.get_by_session_id( + project_id=project_id, session_id=session_id + ) + assert normal_read is None + + # Visible via the archived-inclusive read, with deleted_at set. + archived_row = await streams_dao.get_by_session_id_including_archived( + project_id=project_id, session_id=session_id + ) + assert archived_row is not None + assert archived_row.deleted_at is not None + + unarchived_row = await streams_dao.unarchive_by_session_id( + project_id=project_id, user_id=None, session_id=session_id + ) + assert unarchived_row is not None + assert unarchived_row.deleted_at is None + + # Now visible again via the normal read. + normal_read_again = await streams_dao.get_by_session_id( + project_id=project_id, session_id=session_id + ) + assert normal_read_again is not None + assert normal_read_again.deleted_at is None + + +# --------------------------------------------------------------------------- +# MountsDAO.delete_by_session_id — new hard delete of session-bound mounts +# --------------------------------------------------------------------------- + + +async def test_mounts_delete_by_session_id_hard_deletes_and_returns_rows( + mounts_dao, project +): + project_id = project["project_id"] + user_id = project["user_id"] + session_id = f"wp5-mounts-{uuid.uuid4().hex[:8]}" + + mount = await mounts_dao.create_mount( + project_id=project_id, + user_id=user_id, + mount_create=MountCreate( + slug=f"wp5-mount-{uuid.uuid4().hex[:8]}", + name="cwd", + session_id=session_id, + ), + ) + + deleted_mounts = await mounts_dao.delete_by_session_id( + project_id=project_id, session_id=session_id + ) + assert len(deleted_mounts) == 1 + assert deleted_mounts[0].id == mount.id + + fetched = await mounts_dao.fetch_mount(project_id=project_id, mount_id=mount.id) + assert fetched is None + + +async def test_mounts_delete_by_session_id_scoped_to_session(mounts_dao, project): + """A mount bound to a different session must survive another session's delete.""" + project_id = project["project_id"] + user_id = project["user_id"] + session_a = f"wp5-mounts-a-{uuid.uuid4().hex[:8]}" + session_b = f"wp5-mounts-b-{uuid.uuid4().hex[:8]}" + + mount_a = await mounts_dao.create_mount( + project_id=project_id, + user_id=user_id, + mount_create=MountCreate( + slug=f"wp5-mount-a-{uuid.uuid4().hex[:8]}", + name="cwd", + session_id=session_a, + ), + ) + mount_b = await mounts_dao.create_mount( + project_id=project_id, + user_id=user_id, + mount_create=MountCreate( + slug=f"wp5-mount-b-{uuid.uuid4().hex[:8]}", + name="cwd", + session_id=session_b, + ), + ) + + deleted_mounts = await mounts_dao.delete_by_session_id( + project_id=project_id, session_id=session_a + ) + assert [m.id for m in deleted_mounts] == [mount_a.id] + + still_there = await mounts_dao.fetch_mount( + project_id=project_id, mount_id=mount_b.id + ) + assert still_there is not None + + +async def test_mounts_delete_by_session_id_no_mounts_returns_empty(mounts_dao, project): + project_id = project["project_id"] + session_id = f"wp5-mounts-none-{uuid.uuid4().hex[:8]}" + + deleted_mounts = await mounts_dao.delete_by_session_id( + project_id=project_id, session_id=session_id + ) + assert deleted_mounts == [] diff --git a/api/oss/tests/pytest/unit/sessions/test_wp5_mount_teardown.py b/api/oss/tests/pytest/unit/sessions/test_wp5_mount_teardown.py new file mode 100644 index 0000000000..02a8cfc665 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_wp5_mount_teardown.py @@ -0,0 +1,216 @@ +"""WP5 (S7/F1/F2): MountsService session-scoped teardown/archive fan-out. + +Unit-level: fakes MountsDAOInterface + ObjectStore so the orchestration (which +DAO calls happen, which store prefixes get torn down) is pinned without a real +DB or store. delete_session_mounts must call ObjectStore.delete_prefix once per +deleted mount, using the same `_storage_key` prefix the mount's own file ops +use. archive/unarchive_session_mounts must only touch mount rows (soft), never +the store. +""" + +from datetime import datetime, timezone +from typing import List, Optional +from uuid import UUID, uuid4 + +import pytest + +from oss.src.core.mounts.dtos import Mount, MountCreate, MountEdit, MountQuery +from oss.src.core.mounts.service import MountsService + + +_PROJECT = uuid4() +_USER = uuid4() +_SESSION = "session-wp5-mounts" +_BUCKET = "test-bucket" + + +def _mount(*, session_id: Optional[str], slug: str, deleted: bool = False) -> Mount: + return Mount( + id=uuid4(), + project_id=_PROJECT, + session_id=session_id, + slug=slug, + name=slug, + deleted_at=datetime.now(timezone.utc) if deleted else None, + ) + + +class _FakeMountsDAO: + def __init__(self, mounts: Optional[List[Mount]] = None): + self.mounts = {m.id: m for m in (mounts or [])} + self.archive_calls: list[UUID] = [] + self.unarchive_calls: list[UUID] = [] + self.deleted_session_ids: list[str] = [] + + async def create_mount( + self, *, project_id, user_id, mount_create: MountCreate + ) -> Mount: + raise NotImplementedError + + async def upsert_mount( + self, *, project_id, user_id, mount_create: MountCreate + ) -> Mount: + raise NotImplementedError + + async def fetch_mount(self, *, project_id, mount_id) -> Optional[Mount]: + return self.mounts.get(mount_id) + + async def fetch_mount_by_slug(self, *, project_id, slug) -> Optional[Mount]: + raise NotImplementedError + + async def edit_mount( + self, *, project_id, user_id, mount_edit: MountEdit + ) -> Optional[Mount]: + raise NotImplementedError + + async def archive_mount(self, *, project_id, user_id, mount_id) -> Optional[Mount]: + self.archive_calls.append(mount_id) + mount = self.mounts.get(mount_id) + if mount is None: + return None + mount = mount.model_copy(update={"deleted_at": datetime.now(timezone.utc)}) + self.mounts[mount_id] = mount + return mount + + async def unarchive_mount( + self, *, project_id, user_id, mount_id + ) -> Optional[Mount]: + self.unarchive_calls.append(mount_id) + mount = self.mounts.get(mount_id) + if mount is None: + return None + mount = mount.model_copy(update={"deleted_at": None}) + self.mounts[mount_id] = mount + return mount + + async def query_mounts( + self, *, project_id, mount_query: Optional[MountQuery] = None, windowing=None + ) -> List[Mount]: + rows = list(self.mounts.values()) + if mount_query: + if mount_query.session_id is not None: + rows = [m for m in rows if m.session_id == mount_query.session_id] + if not mount_query.include_archived: + rows = [m for m in rows if m.deleted_at is None] + return rows + + async def delete_by_session_id(self, *, project_id, session_id) -> List[Mount]: + self.deleted_session_ids.append(session_id) + matched = [m for m in self.mounts.values() if m.session_id == session_id] + for m in matched: + del self.mounts[m.id] + return matched + + +class _FakeObjectStore: + def __init__(self): + self.delete_prefix_calls: list[dict] = [] + + async def delete_prefix(self, *, bucket: str, prefix: str) -> int: + self.delete_prefix_calls.append({"bucket": bucket, "prefix": prefix}) + return 3 + + +def _service(mounts: List[Mount]): + dao = _FakeMountsDAO(mounts) + store = _FakeObjectStore() + svc = MountsService(mounts_dao=dao, mounts_store=store, bucket=_BUCKET) + return svc, dao, store + + +# --------------------------------------------------------------------------- +# delete_session_mounts — hard delete + object-store prefix teardown +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_delete_session_mounts_deletes_rows_and_object_store_prefixes(): + mount = _mount(session_id=_SESSION, slug="s1") + svc, dao, store = _service([mount]) + + deleted = await svc.delete_session_mounts(project_id=_PROJECT, session_id=_SESSION) + + assert deleted == [mount] + assert dao.deleted_session_ids == [_SESSION] + assert len(store.delete_prefix_calls) == 1 + call = store.delete_prefix_calls[0] + assert call["bucket"] == _BUCKET + assert call["prefix"] == f"mounts/{_PROJECT}/{mount.id}/" + + +@pytest.mark.asyncio +async def test_delete_session_mounts_tears_down_every_bound_mount(): + mount_a = _mount(session_id=_SESSION, slug="cwd") + mount_b = _mount(session_id=_SESSION, slug="claude-projects") + other = _mount(session_id="other-session", slug="cwd-other") + svc, dao, store = _service([mount_a, mount_b, other]) + + deleted = await svc.delete_session_mounts(project_id=_PROJECT, session_id=_SESSION) + + assert {m.id for m in deleted} == {mount_a.id, mount_b.id} + assert len(store.delete_prefix_calls) == 2 + # the untouched session's mount survives + assert other.id in dao.mounts + + +@pytest.mark.asyncio +async def test_delete_session_mounts_no_bound_mounts_is_a_noop(): + svc, dao, store = _service([]) + + deleted = await svc.delete_session_mounts(project_id=_PROJECT, session_id=_SESSION) + + assert deleted == [] + assert store.delete_prefix_calls == [] + + +# --------------------------------------------------------------------------- +# archive_session_mounts / unarchive_session_mounts — soft, reversible +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_archive_session_mounts_soft_archives_and_never_touches_store(): + mount = _mount(session_id=_SESSION, slug="cwd") + svc, dao, store = _service([mount]) + + archived = await svc.archive_session_mounts( + project_id=_PROJECT, user_id=_USER, session_id=_SESSION + ) + + assert len(archived) == 1 + assert archived[0].deleted_at is not None + assert dao.archive_calls == [mount.id] + assert store.delete_prefix_calls == [] + # the row still exists (soft) -- not removed from the DAO's backing map + assert mount.id in dao.mounts + + +@pytest.mark.asyncio +async def test_unarchive_session_mounts_reverses_archive_round_trip(): + mount = _mount(session_id=_SESSION, slug="cwd") + svc, dao, store = _service([mount]) + + await svc.archive_session_mounts( + project_id=_PROJECT, user_id=_USER, session_id=_SESSION + ) + unarchived = await svc.unarchive_session_mounts( + project_id=_PROJECT, user_id=_USER, session_id=_SESSION + ) + + assert len(unarchived) == 1 + assert unarchived[0].deleted_at is None + assert dao.unarchive_calls == [mount.id] + assert store.delete_prefix_calls == [] + + +@pytest.mark.asyncio +async def test_archive_session_mounts_only_touches_bound_mounts(): + bound = _mount(session_id=_SESSION, slug="cwd") + unbound = _mount(session_id=None, slug="agent-mount") + svc, dao, _ = _service([bound, unbound]) + + await svc.archive_session_mounts( + project_id=_PROJECT, user_id=_USER, session_id=_SESSION + ) + + assert dao.archive_calls == [bound.id] diff --git a/api/oss/tests/pytest/unit/sessions/test_wp5_root_router.py b/api/oss/tests/pytest/unit/sessions/test_wp5_root_router.py new file mode 100644 index 0000000000..331ba147dc --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_wp5_root_router.py @@ -0,0 +1,236 @@ +"""WP5 (S7): SessionsRootRouter — /sessions/query, DELETE /sessions/, +/sessions/archive, /sessions/unarchive. + +Mirrors test_record_ingest_endpoint.py's pattern: a mock Request + +patched check_action_access, so RBAC gating and service delegation are +pinned without a live app/DB. Covers: + + - query_sessions: VIEW_SESSIONS gate; delegates to SessionsService.query_sessions + with the parsed SessionQuery + windowing. + - delete_session: EDIT_SESSIONS gate; delegates to SessionsService.delete_session + keyed by session_id (the query param), not stream_id. + - archive_session / unarchive_session: EDIT_SESSIONS gate; delegate to the + matching SessionsService methods. + - every mutation rejects without permission. +""" + +from uuid import uuid4 +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import FastAPI, HTTPException, Request + +from oss.src.apis.fastapi.sessions.router import SessionsRootRouter +from oss.src.apis.fastapi.sessions.models import SessionQueryRequest +from oss.src.core.shared.dtos import Reference, Windowing + + +def _make_authed_request(app: FastAPI, project_id, user_id, method="POST") -> Request: + scope = { + "type": "http", + "method": method, + "path": "/sessions/", + "headers": [], + "app": app, + } + request = Request(scope) + request.state.project_id = str(project_id) + request.state.user_id = str(user_id) + return request + + +def _patched_access(allowed: bool): + return patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=allowed, + ) + + +# --------------------------------------------------------------------------- +# query_sessions +# --------------------------------------------------------------------------- + + +async def test_query_sessions_delegates_to_service(): + sessions_service = AsyncMock() + sessions_service.query_sessions.return_value = [] + router = SessionsRootRouter(sessions_service=sessions_service) + + project_id = uuid4() + user_id = uuid4() + app = FastAPI() + request = _make_authed_request(app, project_id, user_id) + + target_ref = Reference(id=uuid4(), slug="wf", version="v1") + body = SessionQueryRequest( + references=[target_ref], windowing=Windowing(order="descending") + ) + + with _patched_access(True): + result = await router.query_sessions(request=request, body=body) + + assert result.count == 0 + sessions_service.query_sessions.assert_awaited_once() + call_kwargs = sessions_service.query_sessions.await_args.kwargs + assert call_kwargs["project_id"] == project_id + assert call_kwargs["query"].references == [target_ref] + assert call_kwargs["windowing"].order == "descending" + + +async def test_query_sessions_rejects_without_view_permission(): + sessions_service = AsyncMock() + router = SessionsRootRouter(sessions_service=sessions_service) + + project_id = uuid4() + user_id = uuid4() + app = FastAPI() + request = _make_authed_request(app, project_id, user_id) + + with _patched_access(False): + with pytest.raises(HTTPException) as exc_info: + await router.query_sessions(request=request, body=SessionQueryRequest()) + + assert exc_info.value.status_code == 403 + sessions_service.query_sessions.assert_not_awaited() + + +# --------------------------------------------------------------------------- +# delete_session +# --------------------------------------------------------------------------- + + +async def test_delete_session_delegates_to_service_keyed_by_session_id(): + sessions_service = AsyncMock() + router = SessionsRootRouter(sessions_service=sessions_service) + + project_id = uuid4() + user_id = uuid4() + app = FastAPI() + request = _make_authed_request(app, project_id, user_id, method="DELETE") + + with _patched_access(True): + result = await router.delete_session(request=request, session_id="sess-1") + + assert result == {"ok": True} + sessions_service.delete_session.assert_awaited_once() + call_kwargs = sessions_service.delete_session.await_args.kwargs + assert call_kwargs["project_id"] == project_id + assert call_kwargs["session_id"] == "sess-1" + assert "stream_id" not in call_kwargs + + +async def test_delete_session_rejects_without_edit_permission(): + sessions_service = AsyncMock() + router = SessionsRootRouter(sessions_service=sessions_service) + + project_id = uuid4() + user_id = uuid4() + app = FastAPI() + request = _make_authed_request(app, project_id, user_id, method="DELETE") + + with _patched_access(False): + with pytest.raises(HTTPException) as exc_info: + await router.delete_session(request=request, session_id="sess-1") + + assert exc_info.value.status_code == 403 + sessions_service.delete_session.assert_not_awaited() + + +async def test_delete_session_rejects_invalid_session_id(): + sessions_service = AsyncMock() + router = SessionsRootRouter(sessions_service=sessions_service) + + project_id = uuid4() + user_id = uuid4() + app = FastAPI() + request = _make_authed_request(app, project_id, user_id, method="DELETE") + + with pytest.raises(HTTPException) as exc_info: + await router.delete_session(request=request, session_id="../etc/passwd") + + assert exc_info.value.status_code == 400 + sessions_service.delete_session.assert_not_awaited() + + +# --------------------------------------------------------------------------- +# archive_session / unarchive_session +# --------------------------------------------------------------------------- + + +async def test_archive_session_delegates_to_service(): + from oss.src.core.sessions.streams.dtos import SessionStream + + sessions_service = AsyncMock() + project_id = uuid4() + user_id = uuid4() + sessions_service.archive_session.return_value = SessionStream( + id=uuid4(), project_id=project_id, session_id="sess-1" + ) + router = SessionsRootRouter(sessions_service=sessions_service) + + app = FastAPI() + request = _make_authed_request(app, project_id, user_id) + + with _patched_access(True): + result = await router.archive_session(request=request, session_id="sess-1") + + assert result.count == 1 + sessions_service.archive_session.assert_awaited_once() + call_kwargs = sessions_service.archive_session.await_args.kwargs + assert call_kwargs["project_id"] == project_id + assert call_kwargs["user_id"] == user_id + assert call_kwargs["session_id"] == "sess-1" + + +async def test_archive_session_rejects_without_edit_permission(): + sessions_service = AsyncMock() + router = SessionsRootRouter(sessions_service=sessions_service) + + project_id = uuid4() + user_id = uuid4() + app = FastAPI() + request = _make_authed_request(app, project_id, user_id) + + with _patched_access(False): + with pytest.raises(HTTPException) as exc_info: + await router.archive_session(request=request, session_id="sess-1") + + assert exc_info.value.status_code == 403 + sessions_service.archive_session.assert_not_awaited() + + +async def test_unarchive_session_delegates_to_service(): + sessions_service = AsyncMock() + sessions_service.unarchive_session.return_value = None + router = SessionsRootRouter(sessions_service=sessions_service) + + project_id = uuid4() + user_id = uuid4() + app = FastAPI() + request = _make_authed_request(app, project_id, user_id) + + with _patched_access(True): + result = await router.unarchive_session(request=request, session_id="sess-1") + + assert result.count == 0 + sessions_service.unarchive_session.assert_awaited_once() + call_kwargs = sessions_service.unarchive_session.await_args.kwargs + assert call_kwargs["session_id"] == "sess-1" + + +async def test_unarchive_session_rejects_without_edit_permission(): + sessions_service = AsyncMock() + router = SessionsRootRouter(sessions_service=sessions_service) + + project_id = uuid4() + user_id = uuid4() + app = FastAPI() + request = _make_authed_request(app, project_id, user_id) + + with _patched_access(False): + with pytest.raises(HTTPException) as exc_info: + await router.unarchive_session(request=request, session_id="sess-1") + + assert exc_info.value.status_code == 403 + sessions_service.unarchive_session.assert_not_awaited() diff --git a/api/oss/tests/pytest/unit/tracing/test_dao_ingest_identity_columns.py b/api/oss/tests/pytest/unit/tracing/test_dao_ingest_identity_columns.py new file mode 100644 index 0000000000..cf82c27ba9 --- /dev/null +++ b/api/oss/tests/pytest/unit/tracing/test_dao_ingest_identity_columns.py @@ -0,0 +1,103 @@ +"""Unit test for TracingDAO.ingest persisting the promoted session/user/agent +identity columns. + +No live DB: a fake AsyncSession captures the compiled upsert statement so the +DAO's ON CONFLICT DO UPDATE values can be asserted without Postgres. See +docs/designs/testing/testing.boundaries.specs.md, boundary 3. +""" + +from contextlib import asynccontextmanager +from datetime import datetime, timezone +from uuid import uuid4 + +import pytest + +from oss.src.core.tracing.dtos import OTelFlatSpan +from oss.src.dbs.postgres.tracing.dao import TracingDAO + + +class _FakeResult: + pass + + +class _FakeSession: + def __init__(self, executed: list): + self._executed = executed + + async def execute(self, stmt): + self._executed.append(stmt) + return _FakeResult() + + async def commit(self): + pass + + +class _FakeEngine: + def __init__(self): + self.executed: list = [] + + @asynccontextmanager + async def session(self): + yield _FakeSession(self.executed) + + +def _flat_span( + *, span_id: str, parent_id=None, session_id=None, user_id=None, agent_id=None +): + now = datetime.now(timezone.utc) + return OTelFlatSpan( + trace_id=uuid4().hex, + span_id=span_id, + parent_id=parent_id, + span_name="span", + start_time=now, + end_time=now, + session_id=session_id, + user_id=user_id, + agent_id=agent_id, + ) + + +@pytest.mark.anyio +async def test_ingest_root_span_persists_identity_columns(anyio_backend): + assert anyio_backend == "asyncio" + engine = _FakeEngine() + dao = TracingDAO(engine=engine) + + root = _flat_span( + span_id=uuid4().hex, + session_id="sess-1", + user_id="user-1", + agent_id="agent-1", + ) + + await dao.ingest(project_id=uuid4(), user_id=uuid4(), span_dtos=[root]) + + assert len(engine.executed) == 1 + values = engine.executed[0].compile().params + + assert values["session_id"] == "sess-1" + assert values["user_id"] == "user-1" + assert values["agent_id"] == "agent-1" + + +@pytest.mark.anyio +async def test_ingest_child_span_leaves_identity_columns_null(anyio_backend): + assert anyio_backend == "asyncio" + engine = _FakeEngine() + dao = TracingDAO(engine=engine) + + child = _flat_span(span_id=uuid4().hex, parent_id=uuid4().hex) + + await dao.ingest(project_id=uuid4(), user_id=uuid4(), span_dtos=[child]) + + values = engine.executed[0].compile().params + + assert values["session_id"] is None + assert values["user_id"] is None + assert values["agent_id"] is None + + +@pytest.fixture +def anyio_backend(): + return "asyncio" diff --git a/api/oss/tests/pytest/unit/tracing/utils/test_trees.py b/api/oss/tests/pytest/unit/tracing/utils/test_trees.py index 5414693054..be94c669da 100644 --- a/api/oss/tests/pytest/unit/tracing/utils/test_trees.py +++ b/api/oss/tests/pytest/unit/tracing/utils/test_trees.py @@ -18,6 +18,7 @@ infer_and_propagate_trace_type_by_trace, parse_span_dtos_to_span_idx, parse_span_idx_to_span_id_tree, + promote_identity_by_trace, trace_map_to_traces, traces_to_trace_map, ) @@ -43,6 +44,9 @@ def _span( errors: int = 0, start_offset_s: int = 0, span_type: SpanType = SpanType.TASK, + session_id: str | None = None, + user_id: str | None = None, + agent_id: str | None = None, ) -> OTelFlatSpan: total_tokens = prompt_tokens + completion_tokens total_cost = prompt_cost + completion_cost @@ -65,6 +69,18 @@ def _span( if errors: metrics["errors"] = {"incremental": errors} + ag_attributes = { + "data": {"parameters": {"model": "gpt-4o-mini"}}, + "meta": {"response": {"model": "gpt-4o-mini"}}, + "metrics": metrics, + } + if session_id is not None: + ag_attributes["session"] = {"id": session_id} + if user_id is not None: + ag_attributes["user"] = {"id": user_id} + if agent_id is not None: + ag_attributes["agent"] = {"id": agent_id} + return OTelFlatSpan( trace_id=trace_id, span_id=span_id, @@ -73,13 +89,7 @@ def _span( span_type=span_type, start_time=datetime(2024, 1, 1, 0, 0, start_offset_s, tzinfo=timezone.utc), links=links, - attributes={ - "ag": { - "data": {"parameters": {"model": "gpt-4o-mini"}}, - "meta": {"response": {"model": "gpt-4o-mini"}}, - "metrics": metrics, - } - }, + attributes={"ag": ag_attributes}, ) @@ -365,3 +375,59 @@ def test_trace_map_to_traces_and_back_and_get_span_helpers(): assert get_span_from_trace(trace, ROOT_UUID) is not None assert get_span_from_trace(trace, CHILD_A_UUID) is not None assert get_span_from_trace(trace, str(UUID(int=1))) is None + + +def test_promote_identity_by_trace_lifts_root_only(): + root = _span( + span_id=ROOT_UUID, + span_name="root", + session_id="sess-1", + user_id="user-1", + agent_id="agent-1", + ) + child = _span( + span_id=CHILD_A_UUID, + parent_id=ROOT_UUID, + span_name="child", + start_offset_s=1, + ) + + out = promote_identity_by_trace([root, child]) + out_idx = {span.span_id: span for span in out} + + assert out_idx[ROOT_UUID].session_id == "sess-1" + assert out_idx[ROOT_UUID].user_id == "user-1" + assert out_idx[ROOT_UUID].agent_id == "agent-1" + + assert out_idx[CHILD_A_UUID].session_id is None + assert out_idx[CHILD_A_UUID].user_id is None + assert out_idx[CHILD_A_UUID].agent_id is None + + +def test_promote_identity_by_trace_is_per_trace_and_ignores_missing_attrs(): + trace_a = "trace-a" + trace_b = "trace-b" + + root_a = _span( + span_id="root-a", + span_name="root", + trace_id=trace_a, + session_id="sess-a", + ) + root_b = _span( + span_id="root-b", + span_name="root", + trace_id=trace_b, + ) + + out = promote_identity_by_trace([root_a, root_b]) + out_idx = {span.span_id: span for span in out} + + assert out_idx["root-a"].session_id == "sess-a" + assert out_idx["root-b"].session_id is None + assert out_idx["root-b"].user_id is None + assert out_idx["root-b"].agent_id is None + + +def test_promote_identity_by_trace_empty_list_is_noop(): + assert promote_identity_by_trace([]) == [] diff --git a/api/pyproject.toml b/api/pyproject.toml index ebeff9eb63..1fc005cf03 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "sqlalchemy>=2,<3", "sqlalchemy-utils>=0.42,<0.43", "sqlalchemy-json>=0.7,<0.8", + "greenlet>=3,<4", "asyncpg>=0.31,<0.32", "alembic>=1,<2", "orjson>=3,<4", diff --git a/api/uv.lock b/api/uv.lock index 4e96f63fc2..9dbb87a5a3 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -285,6 +285,7 @@ dependencies = [ { name = "dnspython" }, { name = "fastapi" }, { name = "genson" }, + { name = "greenlet" }, { name = "gunicorn" }, { name = "httpx" }, { name = "jsonschema" }, @@ -338,6 +339,7 @@ requires-dist = [ { name = "dnspython", specifier = ">=2,<3" }, { name = "fastapi", specifier = ">=0.139,<0.140" }, { name = "genson", specifier = ">=1,<2" }, + { name = "greenlet", specifier = ">=3,<4" }, { name = "gunicorn", specifier = ">=26,<27" }, { name = "httpx", specifier = ">=0.28,<0.29" }, { name = "jsonschema", specifier = ">=4,<5" }, diff --git a/clients/python/agenta_client/__init__.py b/clients/python/agenta_client/__init__.py index 724cc3192e..e9360262bc 100644 --- a/clients/python/agenta_client/__init__.py +++ b/clients/python/agenta_client/__init__.py @@ -5,7 +5,7 @@ import typing from importlib import import_module if typing.TYPE_CHECKING: - from .types import AdminAccountCreateOptions, AdminAccountRead, AdminAccountsCreate, AdminAccountsDelete, AdminAccountsDeleteTarget, AdminAccountsResponse, AdminApiKeyCreate, AdminApiKeyResponse, AdminDeleteResponse, AdminDeletedEntities, AdminDeletedEntity, AdminOrganizationCreate, AdminOrganizationMembershipCreate, AdminOrganizationMembershipRead, AdminOrganizationRead, AdminProjectCreate, AdminProjectMembershipCreate, AdminProjectMembershipRead, AdminProjectRead, AdminSimpleAccountCreate, AdminSimpleAccountDeleteEntry, AdminSimpleAccountRead, AdminSimpleAccountsApiKeysCreate, AdminSimpleAccountsCreate, AdminSimpleAccountsDelete, AdminSimpleAccountsOrganizationsCreate, AdminSimpleAccountsOrganizationsMembershipsCreate, AdminSimpleAccountsOrganizationsTransferOwnership, AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjects, AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjectsZero, AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspaces, AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspacesZero, AdminSimpleAccountsOrganizationsTransferOwnershipResponse, AdminSimpleAccountsProjectsCreate, AdminSimpleAccountsProjectsMembershipsCreate, AdminSimpleAccountsResponse, AdminSimpleAccountsUsersCreate, AdminSimpleAccountsUsersIdentitiesCreate, AdminSimpleAccountsUsersResetPassword, AdminSimpleAccountsWorkspacesCreate, AdminSimpleAccountsWorkspacesMembershipsCreate, AdminStructuredError, AdminSubscriptionCreate, AdminSubscriptionRead, AdminUserCreate, AdminUserIdentityCreate, AdminUserIdentityRead, AdminUserIdentityReadStatus, AdminUserRead, AdminWorkspaceCreate, AdminWorkspaceMembershipCreate, AdminWorkspaceMembershipRead, AdminWorkspaceRead, AgentTemplateOverlay, Analytics, AnalyticsResponse, Annotation, AnnotationCreate, AnnotationCreateLinks, AnnotationEdit, AnnotationEditLinks, AnnotationLinkResponse, AnnotationLinks, AnnotationQuery, AnnotationQueryLinks, AnnotationResponse, AnnotationsResponse, Application, ApplicationArtifactFlags, ApplicationArtifactQueryFlags, ApplicationCatalogPreset, ApplicationCatalogPresetResponse, ApplicationCatalogPresetsResponse, ApplicationCatalogTemplate, ApplicationCatalogTemplateResponse, ApplicationCatalogTemplatesResponse, ApplicationCatalogType, ApplicationCatalogTypesResponse, ApplicationCreate, ApplicationEdit, ApplicationFlags, ApplicationQuery, ApplicationResponse, ApplicationRevisionCommit, ApplicationRevisionCreate, ApplicationRevisionDataInput, ApplicationRevisionDataInputHeadersValue, ApplicationRevisionDataInputRuntime, ApplicationRevisionDataOutput, ApplicationRevisionDataOutputHeadersValue, ApplicationRevisionDataOutputRuntime, ApplicationRevisionEdit, ApplicationRevisionFlags, ApplicationRevisionInput, ApplicationRevisionOutput, ApplicationRevisionQuery, ApplicationRevisionQueryFlags, ApplicationRevisionResolveResponse, ApplicationRevisionResponse, ApplicationRevisionsLog, ApplicationRevisionsResponse, ApplicationVariant, ApplicationVariantCreate, ApplicationVariantEdit, ApplicationVariantFlags, ApplicationVariantFork, ApplicationVariantResponse, ApplicationVariantsResponse, ApplicationsResponse, BodyConfigsFetchVariantsConfigsFetchPost, Bucket, BuiltinToolConfig, BuiltinToolConfigPermission, CapabilitiesResult, Capability, CapabilityConnection, CapabilityGuidance, CollectStatusResponse, CommandMode, ComparisonOperator, Condition, ConditionOperator, ConditionOptions, ConditionValue, ConfigResponseModel, ConnectAffordance, ConnectionRequirement, CustomModelSettingsDto, CustomProviderDto, CustomProviderKind, CustomProviderSettingsDto, CustomSecretDto, CustomSecretFormat, CustomSecretSettingsDto, CustomSecretSettingsDtoContent, CustomSecretSettingsDtoContentOneValue, DictOperator, DiscoverResponse, DiscoverResponseMethodsValue, DiscoveredAlternative, DiscoveredTool, DiscoveredToolType, DiscoveredTriggerAlternative, DiscoveredTriggerEvent, DiscoveredTriggerEventType, EntityRef, Environment, EnvironmentCreate, EnvironmentEdit, EnvironmentFlags, EnvironmentQueryFlags, EnvironmentResponse, EnvironmentRevisionCommit, EnvironmentRevisionCreate, EnvironmentRevisionData, EnvironmentRevisionDelta, EnvironmentRevisionEdit, EnvironmentRevisionInput, EnvironmentRevisionOutput, EnvironmentRevisionResolveResponse, EnvironmentRevisionResponse, EnvironmentRevisionsLog, EnvironmentRevisionsResponse, EnvironmentVariant, EnvironmentVariantCreate, EnvironmentVariantEdit, EnvironmentVariantFork, EnvironmentVariantResponse, EnvironmentVariantsResponse, EnvironmentsResponse, ErrorPolicy, EvaluationMetrics, EvaluationMetricsCreate, EvaluationMetricsIdsResponse, EvaluationMetricsQuery, EvaluationMetricsQueryScenarioIds, EvaluationMetricsQueryTimestamps, EvaluationMetricsRefresh, EvaluationMetricsResponse, EvaluationMetricsSetRequest, EvaluationQueue, EvaluationQueueCreate, EvaluationQueueData, EvaluationQueueEdit, EvaluationQueueFlags, EvaluationQueueIdResponse, EvaluationQueueIdsResponse, EvaluationQueueQuery, EvaluationQueueQueryFlags, EvaluationQueueResponse, EvaluationQueueScenariosQuery, EvaluationQueuesResponse, EvaluationResult, EvaluationResultCreate, EvaluationResultIdResponse, EvaluationResultIdsResponse, EvaluationResultQuery, EvaluationResultResponse, EvaluationResultsResponse, EvaluationResultsSetRequest, EvaluationRun, EvaluationRunCreate, EvaluationRunDataConcurrency, EvaluationRunDataInput, EvaluationRunDataMapping, EvaluationRunDataMappingColumn, EvaluationRunDataMappingStep, EvaluationRunDataOutput, EvaluationRunDataStepInput, EvaluationRunDataStepInputKey, EvaluationRunDataStepInputOrigin, EvaluationRunDataStepInputType, EvaluationRunDataStepOutput, EvaluationRunDataStepOutputOrigin, EvaluationRunDataStepOutputType, EvaluationRunEdit, EvaluationRunFlags, EvaluationRunIdResponse, EvaluationRunIdsRequest, EvaluationRunIdsResponse, EvaluationRunQuery, EvaluationRunQueryFlags, EvaluationRunResponse, EvaluationRunsResponse, EvaluationScenario, EvaluationScenarioCreate, EvaluationScenarioEdit, EvaluationScenarioIdResponse, EvaluationScenarioIdsResponse, EvaluationScenarioQuery, EvaluationScenarioResponse, EvaluationScenariosResponse, EvaluationStatus, Evaluator, EvaluatorArtifactFlags, EvaluatorArtifactQueryFlags, EvaluatorCatalogPreset, EvaluatorCatalogPresetResponse, EvaluatorCatalogPresetsResponse, EvaluatorCatalogTemplate, EvaluatorCatalogTemplateResponse, EvaluatorCatalogTemplatesResponse, EvaluatorCatalogType, EvaluatorCatalogTypesResponse, EvaluatorCreate, EvaluatorEdit, EvaluatorFlags, EvaluatorQuery, EvaluatorResponse, EvaluatorRevisionCommit, EvaluatorRevisionCreate, EvaluatorRevisionDataInput, EvaluatorRevisionDataInputHeadersValue, EvaluatorRevisionDataInputRuntime, EvaluatorRevisionDataOutput, EvaluatorRevisionDataOutputHeadersValue, EvaluatorRevisionDataOutputRuntime, EvaluatorRevisionEdit, EvaluatorRevisionFlags, EvaluatorRevisionInput, EvaluatorRevisionOutput, EvaluatorRevisionQuery, EvaluatorRevisionQueryFlags, EvaluatorRevisionResolveResponse, EvaluatorRevisionResponse, EvaluatorRevisionsLog, EvaluatorRevisionsResponse, EvaluatorTemplate, EvaluatorTemplatesResponse, EvaluatorVariant, EvaluatorVariantCreate, EvaluatorVariantEdit, EvaluatorVariantFlags, EvaluatorVariantFork, EvaluatorVariantResponse, EvaluatorVariantsResponse, EvaluatorsResponse, Event, EventQuery, EventType, EventsQueryResponse, ExistenceOperator, FilteringInput, FilteringInputConditionsItem, FilteringOutput, FilteringOutputConditionsItem, Focus, Folder, FolderCreate, FolderEdit, FolderIdResponse, FolderKind, FolderQuery, FolderQueryKinds, FolderResponse, FoldersResponse, Format, Formatting, FullJsonInput, FullJsonOutput, GatewayToolConfig, GatewayToolConfigPermission, HarnessSessionRecord, Header, HttpValidationError, InviteRequest, Invocation, InvocationCreate, InvocationCreateLinks, InvocationEdit, InvocationEditLinks, InvocationLinkResponse, InvocationLinks, InvocationQuery, InvocationQueryLinks, InvocationResponse, InvocationsResponse, JsonSchemasInput, JsonSchemasOutput, LabelJsonInput, LabelJsonOutput, LegacyLifecycleDto, ListApiKeysResponse, ListOperator, ListOptions, LogicalOperator, MetricSpec, MetricType, MetricsBucket, Mount, MountCreate, MountCredentials, MountCredentialsResponse, MountData, MountEdit, MountFileDeletedResponse, MountFileWrittenResponse, MountFlags, MountFolderCreatedResponse, MountQuery, MountResponse, MountsResponse, NumericOperator, OTelEventInput, OTelEventInputTimestamp, OTelEventOutput, OTelEventOutputTimestamp, OTelHashInput, OTelHashOutput, OTelLinkInput, OTelLinkOutput, OTelLinksResponse, OTelReferenceInput, OTelReferenceOutput, OTelSpanKind, OTelStatusCode, OTelTracingRequest, OTelTracingResponse, OldAnalyticsResponse, Organization, OrganizationDetails, OrganizationDomainResponse, OrganizationProviderResponse, OrganizationUpdate, Permission, PlaygroundBuildKitContext, ProjectsResponse, QueriesResponse, Query, QueryCreate, QueryEdit, QueryFlags, QueryQueryFlags, QueryResponse, QueryRevision, QueryRevisionCommit, QueryRevisionCreate, QueryRevisionDataInput, QueryRevisionDataOutput, QueryRevisionEdit, QueryRevisionQuery, QueryRevisionResponse, QueryRevisionsLog, QueryRevisionsResponse, QueryVariant, QueryVariantCreate, QueryVariantEdit, QueryVariantFork, QueryVariantQuery, QueryVariantResponse, QueryVariantsResponse, Reference, ReferenceRequestModelInput, ReferenceRequestModelOutput, RequestType, ResolutionInfo, ResolvedTool, RetrievalInfo, SecretDto, SecretDtoData, SecretKind, SecretResponseDto, SecretResponseDtoData, Selector, SessionHeartbeatResponseModel, SessionIdsResponse, SessionInteraction, SessionInteractionData, SessionInteractionFlags, SessionInteractionKind, SessionInteractionQuery, SessionInteractionQueryFlags, SessionInteractionResponse, SessionInteractionStatus, SessionInteractionsResponse, SessionMount, SessionMountQuery, SessionMountsResponse, SessionRecord, SessionRecordResponse, SessionRecordsQueryResponse, SessionState, SessionStateData, SessionStateFlags, SessionStateResponse, SessionStateUpsertRequest, SessionStream, SessionStreamCommandResponseModel, SessionStreamFlags, SessionStreamResponseModel, SessionStreamsResponseModel, SimpleApplication, SimpleApplicationAdditionalContext, SimpleApplicationCreate, SimpleApplicationDataInput, SimpleApplicationDataInputHeadersValue, SimpleApplicationDataInputRuntime, SimpleApplicationDataOutput, SimpleApplicationDataOutputHeadersValue, SimpleApplicationDataOutputRuntime, SimpleApplicationEdit, SimpleApplicationFlags, SimpleApplicationQuery, SimpleApplicationQueryFlags, SimpleApplicationResponse, SimpleApplicationsResponse, SimpleEnvironment, SimpleEnvironmentCreate, SimpleEnvironmentEdit, SimpleEnvironmentQuery, SimpleEnvironmentResponse, SimpleEnvironmentsResponse, SimpleEvaluation, SimpleEvaluationCreate, SimpleEvaluationData, SimpleEvaluationDataApplicationSteps, SimpleEvaluationDataApplicationStepsOneValue, SimpleEvaluationDataEvaluatorSteps, SimpleEvaluationDataEvaluatorStepsOneValue, SimpleEvaluationDataQuerySteps, SimpleEvaluationDataQueryStepsOneValue, SimpleEvaluationDataTestsetSteps, SimpleEvaluationDataTestsetStepsOneValue, SimpleEvaluationEdit, SimpleEvaluationIdResponse, SimpleEvaluationQuery, SimpleEvaluationResponse, SimpleEvaluationsResponse, SimpleEvaluator, SimpleEvaluatorCreate, SimpleEvaluatorDataInput, SimpleEvaluatorDataInputHeadersValue, SimpleEvaluatorDataInputRuntime, SimpleEvaluatorDataOutput, SimpleEvaluatorDataOutputHeadersValue, SimpleEvaluatorDataOutputRuntime, SimpleEvaluatorEdit, SimpleEvaluatorFlags, SimpleEvaluatorQuery, SimpleEvaluatorQueryFlags, SimpleEvaluatorResponse, SimpleEvaluatorsResponse, SimpleQueriesResponse, SimpleQuery, SimpleQueryCreate, SimpleQueryEdit, SimpleQueryQuery, SimpleQueryResponse, SimpleQueue, SimpleQueueCreate, SimpleQueueData, SimpleQueueDataEvaluators, SimpleQueueDataEvaluatorsOneValue, SimpleQueueIdResponse, SimpleQueueIdsResponse, SimpleQueueKind, SimpleQueueQuery, SimpleQueueResponse, SimpleQueueScenariosQuery, SimpleQueueScenariosResponse, SimpleQueueSettings, SimpleQueuesResponse, SimpleTestset, SimpleTestsetCreate, SimpleTestsetEdit, SimpleTestsetQuery, SimpleTestsetResponse, SimpleTestsetsResponse, SimpleTrace, SimpleTraceChannel, SimpleTraceCreate, SimpleTraceCreateLinks, SimpleTraceEdit, SimpleTraceEditLinks, SimpleTraceKind, SimpleTraceLinkResponse, SimpleTraceLinks, SimpleTraceOrigin, SimpleTraceQuery, SimpleTraceQueryLinks, SimpleTraceReferences, SimpleTraceResponse, SimpleTracesResponse, SimpleWorkflow, SimpleWorkflowCreate, SimpleWorkflowDataInput, SimpleWorkflowDataInputHeadersValue, SimpleWorkflowDataInputRuntime, SimpleWorkflowDataOutput, SimpleWorkflowDataOutputHeadersValue, SimpleWorkflowDataOutputRuntime, SimpleWorkflowEdit, SimpleWorkflowFlags, SimpleWorkflowQuery, SimpleWorkflowQueryFlags, SimpleWorkflowResponse, SimpleWorkflowsResponse, SpanInput, SpanInputEndTime, SpanInputStartTime, SpanOutput, SpanOutputEndTime, SpanOutputStartTime, SpanResponse, SpanType, SpansNodeInput, SpansNodeInputEndTime, SpansNodeInputSpansValue, SpansNodeInputStartTime, SpansNodeOutput, SpansNodeOutputEndTime, SpansNodeOutputSpansValue, SpansNodeOutputStartTime, SpansResponse, SpansTreeInput, SpansTreeInputSpansValue, SpansTreeOutput, SpansTreeOutputSpansValue, SsoProviderDto, SsoProviderInfo, SsoProviderSettingsDto, SsoProviders, StandardProviderDto, StandardProviderKind, StandardProviderSettingsDto, Status, StringOperator, TestcaseInput, TestcaseOutput, TestcaseResponse, TestcasesResponse, Testset, TestsetCreate, TestsetEdit, TestsetFlags, TestsetQuery, TestsetResponse, TestsetRevision, TestsetRevisionCommit, TestsetRevisionCreate, TestsetRevisionDataInput, TestsetRevisionDataOutput, TestsetRevisionDelta, TestsetRevisionDeltaColumns, TestsetRevisionDeltaRows, TestsetRevisionEdit, TestsetRevisionQuery, TestsetRevisionResponse, TestsetRevisionsLog, TestsetRevisionsResponse, TestsetVariant, TestsetVariantCreate, TestsetVariantEdit, TestsetVariantFork, TestsetVariantQuery, TestsetVariantResponse, TestsetVariantsResponse, TestsetsResponse, TextOptions, ToolAuthScheme, ToolCallData, ToolCallFunction, ToolCallResponse, ToolCatalogAction, ToolCatalogActionDetails, ToolCatalogActionResponse, ToolCatalogActionResponseAction, ToolCatalogActionsResponse, ToolCatalogActionsResponseActionsItem, ToolCatalogCategoriesResponse, ToolCatalogCategory, ToolCatalogIntegration, ToolCatalogIntegrationDetails, ToolCatalogIntegrationResponse, ToolCatalogIntegrationResponseIntegration, ToolCatalogIntegrationsResponse, ToolCatalogIntegrationsResponseIntegrationsItem, ToolCatalogProvider, ToolCatalogProviderDetails, ToolCatalogProviderResponse, ToolCatalogProviderResponseProvider, ToolCatalogProvidersResponse, ToolCatalogProvidersResponseProvidersItem, ToolConnection, ToolConnectionCreate, ToolConnectionCreateData, ToolConnectionResponse, ToolConnectionState, ToolConnectionStatus, ToolConnectionsResponse, ToolProviderKind, ToolResolveResponse, ToolResult, ToolResultData, TraceIdResponse, TraceIdsResponse, TraceInput, TraceInputSpansValue, TraceOutput, TraceOutputSpansValue, TraceRequest, TraceResponse, TraceType, TracesRequest, TracesResponse, TracingQuery, TriggerAuthScheme, TriggerCapabilitiesResult, TriggerCapability, TriggerCapabilityConnection, TriggerCatalogEvent, TriggerCatalogEventDetails, TriggerCatalogEventResponse, TriggerCatalogEventsResponse, TriggerCatalogIntegration, TriggerCatalogIntegrationResponse, TriggerCatalogIntegrationsResponse, TriggerCatalogProvider, TriggerCatalogProviderResponse, TriggerCatalogProvidersResponse, TriggerConnectAffordance, TriggerConnection, TriggerConnectionCreate, TriggerConnectionCreateData, TriggerConnectionRequirement, TriggerConnectionResponse, TriggerConnectionStatus, TriggerConnectionsResponse, TriggerDeliveriesResponse, TriggerDelivery, TriggerDeliveryData, TriggerDeliveryQuery, TriggerDeliveryResponse, TriggerDiscoveryConnectionState, TriggerDiscoveryGuidance, TriggerEventAck, TriggerProviderKind, TriggerSchedule, TriggerScheduleCreate, TriggerScheduleData, TriggerScheduleDataInputsFields, TriggerScheduleEdit, TriggerScheduleFlags, TriggerScheduleQuery, TriggerScheduleResponse, TriggerSchedulesResponse, TriggerSubscription, TriggerSubscriptionCreate, TriggerSubscriptionCreateRequest, TriggerSubscriptionData, TriggerSubscriptionDataInputsFields, TriggerSubscriptionEdit, TriggerSubscriptionFlags, TriggerSubscriptionQuery, TriggerSubscriptionResponse, TriggerSubscriptionsResponse, UserIdsResponse, ValidationError, ValidationErrorLocItem, WebhookDeliveriesResponse, WebhookDelivery, WebhookDeliveryCreate, WebhookDeliveryData, WebhookDeliveryQuery, WebhookDeliveryResponse, WebhookDeliveryResponseInfo, WebhookEventType, WebhookProviderDto, WebhookProviderSettingsDto, WebhookSubscription, WebhookSubscriptionCreate, WebhookSubscriptionData, WebhookSubscriptionDataAuthMode, WebhookSubscriptionEdit, WebhookSubscriptionFlags, WebhookSubscriptionQuery, WebhookSubscriptionResponse, WebhookSubscriptionsResponse, Windowing, WindowingOrder, Workflow, WorkflowArtifactFlags, WorkflowCatalogFlags, WorkflowCatalogHarness, WorkflowCatalogHarnessResponse, WorkflowCatalogHarnessesResponse, WorkflowCatalogPreset, WorkflowCatalogPresetResponse, WorkflowCatalogPresetsResponse, WorkflowCatalogTemplate, WorkflowCatalogTemplateResponse, WorkflowCatalogTemplatesResponse, WorkflowCatalogType, WorkflowCatalogTypeResponse, WorkflowCatalogTypesResponse, WorkflowCreate, WorkflowEdit, WorkflowFlags, WorkflowResponse, WorkflowRevisionCommit, WorkflowRevisionCreate, WorkflowRevisionDataInput, WorkflowRevisionDataInputHeadersValue, WorkflowRevisionDataInputRuntime, WorkflowRevisionDataOutput, WorkflowRevisionDataOutputHeadersValue, WorkflowRevisionDataOutputRuntime, WorkflowRevisionDelta, WorkflowRevisionEdit, WorkflowRevisionFlags, WorkflowRevisionInput, WorkflowRevisionOutput, WorkflowRevisionResolveResponse, WorkflowRevisionResponse, WorkflowRevisionsLog, WorkflowRevisionsResponse, WorkflowVariant, WorkflowVariantCreate, WorkflowVariantEdit, WorkflowVariantFlags, WorkflowVariantFork, WorkflowVariantResponse, WorkflowVariantsResponse, WorkflowsResponse, Workspace, WorkspaceMemberResponse, WorkspacePermission, WorkspaceResponse + from .types import AdminAccountCreateOptions, AdminAccountRead, AdminAccountsCreate, AdminAccountsDelete, AdminAccountsDeleteTarget, AdminAccountsResponse, AdminApiKeyCreate, AdminApiKeyResponse, AdminDeleteResponse, AdminDeletedEntities, AdminDeletedEntity, AdminOrganizationCreate, AdminOrganizationMembershipCreate, AdminOrganizationMembershipRead, AdminOrganizationRead, AdminProjectCreate, AdminProjectMembershipCreate, AdminProjectMembershipRead, AdminProjectRead, AdminSimpleAccountCreate, AdminSimpleAccountDeleteEntry, AdminSimpleAccountRead, AdminSimpleAccountsApiKeysCreate, AdminSimpleAccountsCreate, AdminSimpleAccountsDelete, AdminSimpleAccountsOrganizationsCreate, AdminSimpleAccountsOrganizationsMembershipsCreate, AdminSimpleAccountsOrganizationsTransferOwnership, AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjects, AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjectsZero, AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspaces, AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspacesZero, AdminSimpleAccountsOrganizationsTransferOwnershipResponse, AdminSimpleAccountsProjectsCreate, AdminSimpleAccountsProjectsMembershipsCreate, AdminSimpleAccountsResponse, AdminSimpleAccountsUsersCreate, AdminSimpleAccountsUsersIdentitiesCreate, AdminSimpleAccountsUsersResetPassword, AdminSimpleAccountsWorkspacesCreate, AdminSimpleAccountsWorkspacesMembershipsCreate, AdminStructuredError, AdminSubscriptionCreate, AdminSubscriptionRead, AdminUserCreate, AdminUserIdentityCreate, AdminUserIdentityRead, AdminUserIdentityReadStatus, AdminUserRead, AdminWorkspaceCreate, AdminWorkspaceMembershipCreate, AdminWorkspaceMembershipRead, AdminWorkspaceRead, AgentTemplateOverlay, Analytics, AnalyticsResponse, Annotation, AnnotationCreate, AnnotationCreateLinks, AnnotationEdit, AnnotationEditLinks, AnnotationLinkResponse, AnnotationLinks, AnnotationQuery, AnnotationQueryLinks, AnnotationResponse, AnnotationsResponse, Application, ApplicationArtifactFlags, ApplicationArtifactQueryFlags, ApplicationCatalogPreset, ApplicationCatalogPresetResponse, ApplicationCatalogPresetsResponse, ApplicationCatalogTemplate, ApplicationCatalogTemplateResponse, ApplicationCatalogTemplatesResponse, ApplicationCatalogType, ApplicationCatalogTypesResponse, ApplicationCreate, ApplicationEdit, ApplicationFlags, ApplicationQuery, ApplicationResponse, ApplicationRevisionCommit, ApplicationRevisionCreate, ApplicationRevisionDataInput, ApplicationRevisionDataInputHeadersValue, ApplicationRevisionDataInputRuntime, ApplicationRevisionDataOutput, ApplicationRevisionDataOutputHeadersValue, ApplicationRevisionDataOutputRuntime, ApplicationRevisionEdit, ApplicationRevisionFlags, ApplicationRevisionInput, ApplicationRevisionOutput, ApplicationRevisionQuery, ApplicationRevisionQueryFlags, ApplicationRevisionResolveResponse, ApplicationRevisionResponse, ApplicationRevisionsLog, ApplicationRevisionsResponse, ApplicationVariant, ApplicationVariantCreate, ApplicationVariantEdit, ApplicationVariantFlags, ApplicationVariantFork, ApplicationVariantResponse, ApplicationVariantsResponse, ApplicationsResponse, BodyConfigsFetchVariantsConfigsFetchPost, Bucket, BuiltinToolConfig, BuiltinToolConfigPermission, CapabilitiesResult, Capability, CapabilityConnection, CapabilityGuidance, CollectStatusResponse, CommandMode, ComparisonOperator, Condition, ConditionOperator, ConditionOptions, ConditionValue, ConfigResponseModel, ConnectAffordance, ConnectionRequirement, CustomModelSettingsDto, CustomProviderDto, CustomProviderKind, CustomProviderSettingsDto, CustomSecretDto, CustomSecretFormat, CustomSecretSettingsDto, CustomSecretSettingsDtoContent, CustomSecretSettingsDtoContentOneValue, DictOperator, DiscoverResponse, DiscoverResponseMethodsValue, DiscoveredAlternative, DiscoveredTool, DiscoveredToolType, DiscoveredTriggerAlternative, DiscoveredTriggerEvent, DiscoveredTriggerEventType, EntityRef, Environment, EnvironmentCreate, EnvironmentEdit, EnvironmentFlags, EnvironmentQueryFlags, EnvironmentResponse, EnvironmentRevisionCommit, EnvironmentRevisionCreate, EnvironmentRevisionData, EnvironmentRevisionDelta, EnvironmentRevisionEdit, EnvironmentRevisionInput, EnvironmentRevisionOutput, EnvironmentRevisionResolveResponse, EnvironmentRevisionResponse, EnvironmentRevisionsLog, EnvironmentRevisionsResponse, EnvironmentVariant, EnvironmentVariantCreate, EnvironmentVariantEdit, EnvironmentVariantFork, EnvironmentVariantResponse, EnvironmentVariantsResponse, EnvironmentsResponse, ErrorPolicy, EvaluationMetrics, EvaluationMetricsCreate, EvaluationMetricsIdsResponse, EvaluationMetricsQuery, EvaluationMetricsQueryScenarioIds, EvaluationMetricsQueryTimestamps, EvaluationMetricsRefresh, EvaluationMetricsResponse, EvaluationMetricsSetRequest, EvaluationQueue, EvaluationQueueCreate, EvaluationQueueData, EvaluationQueueEdit, EvaluationQueueFlags, EvaluationQueueIdResponse, EvaluationQueueIdsResponse, EvaluationQueueQuery, EvaluationQueueQueryFlags, EvaluationQueueResponse, EvaluationQueueScenariosQuery, EvaluationQueuesResponse, EvaluationResult, EvaluationResultCreate, EvaluationResultIdResponse, EvaluationResultIdsResponse, EvaluationResultQuery, EvaluationResultResponse, EvaluationResultsResponse, EvaluationResultsSetRequest, EvaluationRun, EvaluationRunCreate, EvaluationRunDataConcurrency, EvaluationRunDataInput, EvaluationRunDataMapping, EvaluationRunDataMappingColumn, EvaluationRunDataMappingStep, EvaluationRunDataOutput, EvaluationRunDataStepInput, EvaluationRunDataStepInputKey, EvaluationRunDataStepInputOrigin, EvaluationRunDataStepInputType, EvaluationRunDataStepOutput, EvaluationRunDataStepOutputOrigin, EvaluationRunDataStepOutputType, EvaluationRunEdit, EvaluationRunFlags, EvaluationRunIdResponse, EvaluationRunIdsRequest, EvaluationRunIdsResponse, EvaluationRunQuery, EvaluationRunQueryFlags, EvaluationRunResponse, EvaluationRunsResponse, EvaluationScenario, EvaluationScenarioCreate, EvaluationScenarioEdit, EvaluationScenarioIdResponse, EvaluationScenarioIdsResponse, EvaluationScenarioQuery, EvaluationScenarioResponse, EvaluationScenariosResponse, EvaluationStatus, Evaluator, EvaluatorArtifactFlags, EvaluatorArtifactQueryFlags, EvaluatorCatalogPreset, EvaluatorCatalogPresetResponse, EvaluatorCatalogPresetsResponse, EvaluatorCatalogTemplate, EvaluatorCatalogTemplateResponse, EvaluatorCatalogTemplatesResponse, EvaluatorCatalogType, EvaluatorCatalogTypesResponse, EvaluatorCreate, EvaluatorEdit, EvaluatorFlags, EvaluatorQuery, EvaluatorResponse, EvaluatorRevisionCommit, EvaluatorRevisionCreate, EvaluatorRevisionDataInput, EvaluatorRevisionDataInputHeadersValue, EvaluatorRevisionDataInputRuntime, EvaluatorRevisionDataOutput, EvaluatorRevisionDataOutputHeadersValue, EvaluatorRevisionDataOutputRuntime, EvaluatorRevisionEdit, EvaluatorRevisionFlags, EvaluatorRevisionInput, EvaluatorRevisionOutput, EvaluatorRevisionQuery, EvaluatorRevisionQueryFlags, EvaluatorRevisionResolveResponse, EvaluatorRevisionResponse, EvaluatorRevisionsLog, EvaluatorRevisionsResponse, EvaluatorTemplate, EvaluatorTemplatesResponse, EvaluatorVariant, EvaluatorVariantCreate, EvaluatorVariantEdit, EvaluatorVariantFlags, EvaluatorVariantFork, EvaluatorVariantResponse, EvaluatorVariantsResponse, EvaluatorsResponse, Event, EventQuery, EventType, EventsQueryResponse, ExistenceOperator, FilteringInput, FilteringInputConditionsItem, FilteringOutput, FilteringOutputConditionsItem, Focus, Folder, FolderCreate, FolderEdit, FolderIdResponse, FolderKind, FolderQuery, FolderQueryKinds, FolderResponse, FoldersResponse, Format, Formatting, FullJsonInput, FullJsonOutput, GatewayToolConfig, GatewayToolConfigPermission, HarnessKind, Header, HttpValidationError, InviteRequest, Invocation, InvocationCreate, InvocationCreateLinks, InvocationEdit, InvocationEditLinks, InvocationLinkResponse, InvocationLinks, InvocationQuery, InvocationQueryLinks, InvocationResponse, InvocationsResponse, JsonSchemasInput, JsonSchemasOutput, LabelJsonInput, LabelJsonOutput, LegacyLifecycleDto, ListApiKeysResponse, ListOperator, ListOptions, LogicalOperator, MetricSpec, MetricType, MetricsBucket, Mount, MountCreate, MountCredentials, MountCredentialsResponse, MountData, MountEdit, MountFileDeletedResponse, MountFileWrittenResponse, MountFlags, MountFolderCreatedResponse, MountQuery, MountResponse, MountsResponse, NumericOperator, OTelEventInput, OTelEventInputTimestamp, OTelEventOutput, OTelEventOutputTimestamp, OTelHashInput, OTelHashOutput, OTelLinkInput, OTelLinkOutput, OTelLinksResponse, OTelReferenceInput, OTelReferenceOutput, OTelSpanKind, OTelStatusCode, OTelTracingRequest, OTelTracingResponse, OldAnalyticsResponse, Organization, OrganizationDetails, OrganizationDomainResponse, OrganizationProviderResponse, OrganizationUpdate, Permission, PlaygroundBuildKitContext, ProjectsResponse, QueriesResponse, Query, QueryCreate, QueryEdit, QueryFlags, QueryQueryFlags, QueryResponse, QueryRevision, QueryRevisionCommit, QueryRevisionCreate, QueryRevisionDataInput, QueryRevisionDataOutput, QueryRevisionEdit, QueryRevisionQuery, QueryRevisionResponse, QueryRevisionsLog, QueryRevisionsResponse, QueryVariant, QueryVariantCreate, QueryVariantEdit, QueryVariantFork, QueryVariantQuery, QueryVariantResponse, QueryVariantsResponse, Reference, ReferenceRequestModelInput, ReferenceRequestModelOutput, RequestType, ResolutionInfo, ResolvedTool, RetrievalInfo, SecretDto, SecretDtoData, SecretKind, SecretResponseDto, SecretResponseDtoData, Selector, SessionHeartbeatResult, SessionIdsResponse, SessionInteraction, SessionInteractionData, SessionInteractionFlags, SessionInteractionKind, SessionInteractionQuery, SessionInteractionQueryFlags, SessionInteractionResponse, SessionInteractionStatus, SessionInteractionsResponse, SessionMount, SessionMountQuery, SessionMountsResponse, SessionRecord, SessionRecordResponse, SessionRecordsQueryResponse, SessionResponse, SessionStream, SessionStreamCommandResponse, SessionStreamFlags, SessionStreamHeaderEdit, SessionStreamResponse, SessionStreamsResponse, SessionTurn, SessionTurnQuery, SessionTurnResponse, SessionTurnsResponse, SessionsResponse, SimpleApplication, SimpleApplicationAdditionalContext, SimpleApplicationCreate, SimpleApplicationDataInput, SimpleApplicationDataInputHeadersValue, SimpleApplicationDataInputRuntime, SimpleApplicationDataOutput, SimpleApplicationDataOutputHeadersValue, SimpleApplicationDataOutputRuntime, SimpleApplicationEdit, SimpleApplicationFlags, SimpleApplicationQuery, SimpleApplicationQueryFlags, SimpleApplicationResponse, SimpleApplicationsResponse, SimpleEnvironment, SimpleEnvironmentCreate, SimpleEnvironmentEdit, SimpleEnvironmentQuery, SimpleEnvironmentResponse, SimpleEnvironmentsResponse, SimpleEvaluation, SimpleEvaluationCreate, SimpleEvaluationData, SimpleEvaluationDataApplicationSteps, SimpleEvaluationDataApplicationStepsOneValue, SimpleEvaluationDataEvaluatorSteps, SimpleEvaluationDataEvaluatorStepsOneValue, SimpleEvaluationDataQuerySteps, SimpleEvaluationDataQueryStepsOneValue, SimpleEvaluationDataTestsetSteps, SimpleEvaluationDataTestsetStepsOneValue, SimpleEvaluationEdit, SimpleEvaluationIdResponse, SimpleEvaluationQuery, SimpleEvaluationResponse, SimpleEvaluationsResponse, SimpleEvaluator, SimpleEvaluatorCreate, SimpleEvaluatorDataInput, SimpleEvaluatorDataInputHeadersValue, SimpleEvaluatorDataInputRuntime, SimpleEvaluatorDataOutput, SimpleEvaluatorDataOutputHeadersValue, SimpleEvaluatorDataOutputRuntime, SimpleEvaluatorEdit, SimpleEvaluatorFlags, SimpleEvaluatorQuery, SimpleEvaluatorQueryFlags, SimpleEvaluatorResponse, SimpleEvaluatorsResponse, SimpleQueriesResponse, SimpleQuery, SimpleQueryCreate, SimpleQueryEdit, SimpleQueryQuery, SimpleQueryResponse, SimpleQueue, SimpleQueueCreate, SimpleQueueData, SimpleQueueDataEvaluators, SimpleQueueDataEvaluatorsOneValue, SimpleQueueIdResponse, SimpleQueueIdsResponse, SimpleQueueKind, SimpleQueueQuery, SimpleQueueResponse, SimpleQueueScenariosQuery, SimpleQueueScenariosResponse, SimpleQueueSettings, SimpleQueuesResponse, SimpleTestset, SimpleTestsetCreate, SimpleTestsetEdit, SimpleTestsetQuery, SimpleTestsetResponse, SimpleTestsetsResponse, SimpleTrace, SimpleTraceChannel, SimpleTraceCreate, SimpleTraceCreateLinks, SimpleTraceEdit, SimpleTraceEditLinks, SimpleTraceKind, SimpleTraceLinkResponse, SimpleTraceLinks, SimpleTraceOrigin, SimpleTraceQuery, SimpleTraceQueryLinks, SimpleTraceReferences, SimpleTraceResponse, SimpleTracesResponse, SimpleWorkflow, SimpleWorkflowCreate, SimpleWorkflowDataInput, SimpleWorkflowDataInputHeadersValue, SimpleWorkflowDataInputRuntime, SimpleWorkflowDataOutput, SimpleWorkflowDataOutputHeadersValue, SimpleWorkflowDataOutputRuntime, SimpleWorkflowEdit, SimpleWorkflowFlags, SimpleWorkflowQuery, SimpleWorkflowQueryFlags, SimpleWorkflowResponse, SimpleWorkflowsResponse, SpanInput, SpanInputEndTime, SpanInputStartTime, SpanOutput, SpanOutputEndTime, SpanOutputStartTime, SpanResponse, SpanType, SpansNodeInput, SpansNodeInputEndTime, SpansNodeInputSpansValue, SpansNodeInputStartTime, SpansNodeOutput, SpansNodeOutputEndTime, SpansNodeOutputSpansValue, SpansNodeOutputStartTime, SpansResponse, SpansTreeInput, SpansTreeInputSpansValue, SpansTreeOutput, SpansTreeOutputSpansValue, SsoProviderDto, SsoProviderInfo, SsoProviderSettingsDto, SsoProviders, StandardProviderDto, StandardProviderKind, StandardProviderSettingsDto, Status, StringOperator, TestcaseInput, TestcaseOutput, TestcaseResponse, TestcasesResponse, Testset, TestsetCreate, TestsetEdit, TestsetFlags, TestsetQuery, TestsetResponse, TestsetRevision, TestsetRevisionCommit, TestsetRevisionCreate, TestsetRevisionDataInput, TestsetRevisionDataOutput, TestsetRevisionDelta, TestsetRevisionDeltaColumns, TestsetRevisionDeltaRows, TestsetRevisionEdit, TestsetRevisionQuery, TestsetRevisionResponse, TestsetRevisionsLog, TestsetRevisionsResponse, TestsetVariant, TestsetVariantCreate, TestsetVariantEdit, TestsetVariantFork, TestsetVariantQuery, TestsetVariantResponse, TestsetVariantsResponse, TestsetsResponse, TextOptions, ToolAuthScheme, ToolCallData, ToolCallFunction, ToolCallResponse, ToolCatalogAction, ToolCatalogActionDetails, ToolCatalogActionResponse, ToolCatalogActionResponseAction, ToolCatalogActionsResponse, ToolCatalogActionsResponseActionsItem, ToolCatalogCategoriesResponse, ToolCatalogCategory, ToolCatalogIntegration, ToolCatalogIntegrationDetails, ToolCatalogIntegrationResponse, ToolCatalogIntegrationResponseIntegration, ToolCatalogIntegrationsResponse, ToolCatalogIntegrationsResponseIntegrationsItem, ToolCatalogProvider, ToolCatalogProviderDetails, ToolCatalogProviderResponse, ToolCatalogProviderResponseProvider, ToolCatalogProvidersResponse, ToolCatalogProvidersResponseProvidersItem, ToolConnection, ToolConnectionCreate, ToolConnectionCreateData, ToolConnectionResponse, ToolConnectionState, ToolConnectionStatus, ToolConnectionsResponse, ToolProviderKind, ToolResolveResponse, ToolResult, ToolResultData, TraceIdResponse, TraceIdsResponse, TraceInput, TraceInputSpansValue, TraceOutput, TraceOutputSpansValue, TraceRequest, TraceResponse, TraceType, TracesRequest, TracesResponse, TracingQuery, TriggerAuthScheme, TriggerCapabilitiesResult, TriggerCapability, TriggerCapabilityConnection, TriggerCatalogEvent, TriggerCatalogEventDetails, TriggerCatalogEventResponse, TriggerCatalogEventsResponse, TriggerCatalogIntegration, TriggerCatalogIntegrationResponse, TriggerCatalogIntegrationsResponse, TriggerCatalogProvider, TriggerCatalogProviderResponse, TriggerCatalogProvidersResponse, TriggerConnectAffordance, TriggerConnection, TriggerConnectionCreate, TriggerConnectionCreateData, TriggerConnectionRequirement, TriggerConnectionResponse, TriggerConnectionStatus, TriggerConnectionsResponse, TriggerDeliveriesResponse, TriggerDelivery, TriggerDeliveryData, TriggerDeliveryQuery, TriggerDeliveryResponse, TriggerDiscoveryConnectionState, TriggerDiscoveryGuidance, TriggerEventAck, TriggerProviderKind, TriggerSchedule, TriggerScheduleCreate, TriggerScheduleData, TriggerScheduleDataInputsFields, TriggerScheduleEdit, TriggerScheduleFlags, TriggerScheduleQuery, TriggerScheduleResponse, TriggerSchedulesResponse, TriggerSubscription, TriggerSubscriptionCreate, TriggerSubscriptionCreateRequest, TriggerSubscriptionData, TriggerSubscriptionDataInputsFields, TriggerSubscriptionEdit, TriggerSubscriptionFlags, TriggerSubscriptionQuery, TriggerSubscriptionResponse, TriggerSubscriptionsResponse, UserIdsResponse, ValidationError, ValidationErrorLocItem, WebhookDeliveriesResponse, WebhookDelivery, WebhookDeliveryCreate, WebhookDeliveryData, WebhookDeliveryQuery, WebhookDeliveryResponse, WebhookDeliveryResponseInfo, WebhookEventType, WebhookProviderDto, WebhookProviderSettingsDto, WebhookSubscription, WebhookSubscriptionCreate, WebhookSubscriptionData, WebhookSubscriptionDataAuthMode, WebhookSubscriptionEdit, WebhookSubscriptionFlags, WebhookSubscriptionQuery, WebhookSubscriptionResponse, WebhookSubscriptionsResponse, Windowing, WindowingOrder, Workflow, WorkflowArtifactFlags, WorkflowCatalogFlags, WorkflowCatalogHarness, WorkflowCatalogHarnessResponse, WorkflowCatalogHarnessesResponse, WorkflowCatalogPreset, WorkflowCatalogPresetResponse, WorkflowCatalogPresetsResponse, WorkflowCatalogTemplate, WorkflowCatalogTemplateResponse, WorkflowCatalogTemplatesResponse, WorkflowCatalogType, WorkflowCatalogTypeResponse, WorkflowCatalogTypesResponse, WorkflowCreate, WorkflowEdit, WorkflowFlags, WorkflowRequestData, WorkflowResponse, WorkflowRevisionCommit, WorkflowRevisionCreate, WorkflowRevisionDataInput, WorkflowRevisionDataInputHeadersValue, WorkflowRevisionDataInputRuntime, WorkflowRevisionDataOutput, WorkflowRevisionDataOutputHeadersValue, WorkflowRevisionDataOutputRuntime, WorkflowRevisionDelta, WorkflowRevisionEdit, WorkflowRevisionFlags, WorkflowRevisionInput, WorkflowRevisionOutput, WorkflowRevisionResolveResponse, WorkflowRevisionResponse, WorkflowRevisionsLog, WorkflowRevisionsResponse, WorkflowVariant, WorkflowVariantCreate, WorkflowVariantEdit, WorkflowVariantFlags, WorkflowVariantFork, WorkflowVariantResponse, WorkflowVariantsResponse, WorkflowsResponse, Workspace, WorkspaceMemberResponse, WorkspacePermission, WorkspaceResponse from .errors import UnprocessableEntityError from . import access, annotations, applications, billing, environments, evaluations, evaluators, events, folders, invocations, keys, legacy, mounts, organizations, projects, queries, secrets, sessions, status, testcases, testsets, tools, traces, triggers, users, webhooks, workflows, workspaces from .applications import QueryApplicationVariantsRequestOrder @@ -20,7 +20,7 @@ from .traces import QuerySpansAnalyticsRequestNewest, QuerySpansAnalyticsRequestOldest from .webhooks import WebhookSubscriptionTestRequestSubscription from .workflows import QueryWorkflowRevisionsRequestOrder, QueryWorkflowVariantsRequestOrder, QueryWorkflowsRequestOrder -_dynamic_imports: typing.Dict[str, str] = {"AdminAccountCreateOptions": ".types", "AdminAccountRead": ".types", "AdminAccountsCreate": ".types", "AdminAccountsDelete": ".types", "AdminAccountsDeleteTarget": ".types", "AdminAccountsResponse": ".types", "AdminApiKeyCreate": ".types", "AdminApiKeyResponse": ".types", "AdminDeleteResponse": ".types", "AdminDeletedEntities": ".types", "AdminDeletedEntity": ".types", "AdminOrganizationCreate": ".types", "AdminOrganizationMembershipCreate": ".types", "AdminOrganizationMembershipRead": ".types", "AdminOrganizationRead": ".types", "AdminProjectCreate": ".types", "AdminProjectMembershipCreate": ".types", "AdminProjectMembershipRead": ".types", "AdminProjectRead": ".types", "AdminSimpleAccountCreate": ".types", "AdminSimpleAccountDeleteEntry": ".types", "AdminSimpleAccountRead": ".types", "AdminSimpleAccountsApiKeysCreate": ".types", "AdminSimpleAccountsCreate": ".types", "AdminSimpleAccountsDelete": ".types", "AdminSimpleAccountsOrganizationsCreate": ".types", "AdminSimpleAccountsOrganizationsMembershipsCreate": ".types", "AdminSimpleAccountsOrganizationsTransferOwnership": ".types", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjects": ".types", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjectsZero": ".types", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspaces": ".types", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspacesZero": ".types", "AdminSimpleAccountsOrganizationsTransferOwnershipResponse": ".types", "AdminSimpleAccountsProjectsCreate": ".types", "AdminSimpleAccountsProjectsMembershipsCreate": ".types", "AdminSimpleAccountsResponse": ".types", "AdminSimpleAccountsUsersCreate": ".types", "AdminSimpleAccountsUsersIdentitiesCreate": ".types", "AdminSimpleAccountsUsersResetPassword": ".types", "AdminSimpleAccountsWorkspacesCreate": ".types", "AdminSimpleAccountsWorkspacesMembershipsCreate": ".types", "AdminStructuredError": ".types", "AdminSubscriptionCreate": ".types", "AdminSubscriptionRead": ".types", "AdminUserCreate": ".types", "AdminUserIdentityCreate": ".types", "AdminUserIdentityRead": ".types", "AdminUserIdentityReadStatus": ".types", "AdminUserRead": ".types", "AdminWorkspaceCreate": ".types", "AdminWorkspaceMembershipCreate": ".types", "AdminWorkspaceMembershipRead": ".types", "AdminWorkspaceRead": ".types", "AgentTemplateOverlay": ".types", "AgentaApi": ".client", "AgentaApiEnvironment": ".environment", "Analytics": ".types", "AnalyticsResponse": ".types", "Annotation": ".types", "AnnotationCreate": ".types", "AnnotationCreateLinks": ".types", "AnnotationEdit": ".types", "AnnotationEditLinks": ".types", "AnnotationLinkResponse": ".types", "AnnotationLinks": ".types", "AnnotationQuery": ".types", "AnnotationQueryLinks": ".types", "AnnotationResponse": ".types", "AnnotationsResponse": ".types", "Application": ".types", "ApplicationArtifactFlags": ".types", "ApplicationArtifactQueryFlags": ".types", "ApplicationCatalogPreset": ".types", "ApplicationCatalogPresetResponse": ".types", "ApplicationCatalogPresetsResponse": ".types", "ApplicationCatalogTemplate": ".types", "ApplicationCatalogTemplateResponse": ".types", "ApplicationCatalogTemplatesResponse": ".types", "ApplicationCatalogType": ".types", "ApplicationCatalogTypesResponse": ".types", "ApplicationCreate": ".types", "ApplicationEdit": ".types", "ApplicationFlags": ".types", "ApplicationQuery": ".types", "ApplicationResponse": ".types", "ApplicationRevisionCommit": ".types", "ApplicationRevisionCreate": ".types", "ApplicationRevisionDataInput": ".types", "ApplicationRevisionDataInputHeadersValue": ".types", "ApplicationRevisionDataInputRuntime": ".types", "ApplicationRevisionDataOutput": ".types", "ApplicationRevisionDataOutputHeadersValue": ".types", "ApplicationRevisionDataOutputRuntime": ".types", "ApplicationRevisionEdit": ".types", "ApplicationRevisionFlags": ".types", "ApplicationRevisionInput": ".types", "ApplicationRevisionOutput": ".types", "ApplicationRevisionQuery": ".types", "ApplicationRevisionQueryFlags": ".types", "ApplicationRevisionResolveResponse": ".types", "ApplicationRevisionResponse": ".types", "ApplicationRevisionsLog": ".types", "ApplicationRevisionsResponse": ".types", "ApplicationVariant": ".types", "ApplicationVariantCreate": ".types", "ApplicationVariantEdit": ".types", "ApplicationVariantFlags": ".types", "ApplicationVariantFork": ".types", "ApplicationVariantResponse": ".types", "ApplicationVariantsResponse": ".types", "ApplicationsResponse": ".types", "AsyncAgentaApi": ".client", "BodyConfigsFetchVariantsConfigsFetchPost": ".types", "Bucket": ".types", "BuiltinToolConfig": ".types", "BuiltinToolConfigPermission": ".types", "CapabilitiesResult": ".types", "Capability": ".types", "CapabilityConnection": ".types", "CapabilityGuidance": ".types", "CollectStatusResponse": ".types", "CommandMode": ".types", "ComparisonOperator": ".types", "Condition": ".types", "ConditionOperator": ".types", "ConditionOptions": ".types", "ConditionValue": ".types", "ConfigResponseModel": ".types", "ConnectAffordance": ".types", "ConnectionRequirement": ".types", "CreateSimpleTestsetFromFileRequestFileType": ".testsets", "CreateTestsetRevisionFromFileRequestFileType": ".testsets", "CustomModelSettingsDto": ".types", "CustomProviderDto": ".types", "CustomProviderKind": ".types", "CustomProviderSettingsDto": ".types", "CustomSecretDto": ".types", "CustomSecretFormat": ".types", "CustomSecretSettingsDto": ".types", "CustomSecretSettingsDtoContent": ".types", "CustomSecretSettingsDtoContentOneValue": ".types", "DictOperator": ".types", "DiscoverResponse": ".types", "DiscoverResponseMethodsValue": ".types", "DiscoveredAlternative": ".types", "DiscoveredTool": ".types", "DiscoveredToolType": ".types", "DiscoveredTriggerAlternative": ".types", "DiscoveredTriggerEvent": ".types", "DiscoveredTriggerEventType": ".types", "EditSimpleTestsetFromFileRequestFileType": ".testsets", "EntityRef": ".types", "Environment": ".types", "EnvironmentCreate": ".types", "EnvironmentEdit": ".types", "EnvironmentFlags": ".types", "EnvironmentQueryFlags": ".types", "EnvironmentResponse": ".types", "EnvironmentRevisionCommit": ".types", "EnvironmentRevisionCreate": ".types", "EnvironmentRevisionData": ".types", "EnvironmentRevisionDelta": ".types", "EnvironmentRevisionEdit": ".types", "EnvironmentRevisionInput": ".types", "EnvironmentRevisionOutput": ".types", "EnvironmentRevisionResolveResponse": ".types", "EnvironmentRevisionResponse": ".types", "EnvironmentRevisionsLog": ".types", "EnvironmentRevisionsResponse": ".types", "EnvironmentVariant": ".types", "EnvironmentVariantCreate": ".types", "EnvironmentVariantEdit": ".types", "EnvironmentVariantFork": ".types", "EnvironmentVariantResponse": ".types", "EnvironmentVariantsResponse": ".types", "EnvironmentsResponse": ".types", "ErrorPolicy": ".types", "EvaluationMetrics": ".types", "EvaluationMetricsCreate": ".types", "EvaluationMetricsIdsResponse": ".types", "EvaluationMetricsQuery": ".types", "EvaluationMetricsQueryScenarioIds": ".types", "EvaluationMetricsQueryTimestamps": ".types", "EvaluationMetricsRefresh": ".types", "EvaluationMetricsResponse": ".types", "EvaluationMetricsSetRequest": ".types", "EvaluationQueue": ".types", "EvaluationQueueCreate": ".types", "EvaluationQueueData": ".types", "EvaluationQueueEdit": ".types", "EvaluationQueueFlags": ".types", "EvaluationQueueIdResponse": ".types", "EvaluationQueueIdsResponse": ".types", "EvaluationQueueQuery": ".types", "EvaluationQueueQueryFlags": ".types", "EvaluationQueueResponse": ".types", "EvaluationQueueScenariosQuery": ".types", "EvaluationQueuesResponse": ".types", "EvaluationResult": ".types", "EvaluationResultCreate": ".types", "EvaluationResultIdResponse": ".types", "EvaluationResultIdsResponse": ".types", "EvaluationResultQuery": ".types", "EvaluationResultResponse": ".types", "EvaluationResultsResponse": ".types", "EvaluationResultsSetRequest": ".types", "EvaluationRun": ".types", "EvaluationRunCreate": ".types", "EvaluationRunDataConcurrency": ".types", "EvaluationRunDataInput": ".types", "EvaluationRunDataMapping": ".types", "EvaluationRunDataMappingColumn": ".types", "EvaluationRunDataMappingStep": ".types", "EvaluationRunDataOutput": ".types", "EvaluationRunDataStepInput": ".types", "EvaluationRunDataStepInputKey": ".types", "EvaluationRunDataStepInputOrigin": ".types", "EvaluationRunDataStepInputType": ".types", "EvaluationRunDataStepOutput": ".types", "EvaluationRunDataStepOutputOrigin": ".types", "EvaluationRunDataStepOutputType": ".types", "EvaluationRunEdit": ".types", "EvaluationRunFlags": ".types", "EvaluationRunIdResponse": ".types", "EvaluationRunIdsRequest": ".types", "EvaluationRunIdsResponse": ".types", "EvaluationRunQuery": ".types", "EvaluationRunQueryFlags": ".types", "EvaluationRunResponse": ".types", "EvaluationRunsResponse": ".types", "EvaluationScenario": ".types", "EvaluationScenarioCreate": ".types", "EvaluationScenarioEdit": ".types", "EvaluationScenarioIdResponse": ".types", "EvaluationScenarioIdsResponse": ".types", "EvaluationScenarioQuery": ".types", "EvaluationScenarioResponse": ".types", "EvaluationScenariosResponse": ".types", "EvaluationStatus": ".types", "Evaluator": ".types", "EvaluatorArtifactFlags": ".types", "EvaluatorArtifactQueryFlags": ".types", "EvaluatorCatalogPreset": ".types", "EvaluatorCatalogPresetResponse": ".types", "EvaluatorCatalogPresetsResponse": ".types", "EvaluatorCatalogTemplate": ".types", "EvaluatorCatalogTemplateResponse": ".types", "EvaluatorCatalogTemplatesResponse": ".types", "EvaluatorCatalogType": ".types", "EvaluatorCatalogTypesResponse": ".types", "EvaluatorCreate": ".types", "EvaluatorEdit": ".types", "EvaluatorFlags": ".types", "EvaluatorQuery": ".types", "EvaluatorResponse": ".types", "EvaluatorRevisionCommit": ".types", "EvaluatorRevisionCreate": ".types", "EvaluatorRevisionDataInput": ".types", "EvaluatorRevisionDataInputHeadersValue": ".types", "EvaluatorRevisionDataInputRuntime": ".types", "EvaluatorRevisionDataOutput": ".types", "EvaluatorRevisionDataOutputHeadersValue": ".types", "EvaluatorRevisionDataOutputRuntime": ".types", "EvaluatorRevisionEdit": ".types", "EvaluatorRevisionFlags": ".types", "EvaluatorRevisionInput": ".types", "EvaluatorRevisionOutput": ".types", "EvaluatorRevisionQuery": ".types", "EvaluatorRevisionQueryFlags": ".types", "EvaluatorRevisionResolveResponse": ".types", "EvaluatorRevisionResponse": ".types", "EvaluatorRevisionsLog": ".types", "EvaluatorRevisionsResponse": ".types", "EvaluatorTemplate": ".types", "EvaluatorTemplatesResponse": ".types", "EvaluatorVariant": ".types", "EvaluatorVariantCreate": ".types", "EvaluatorVariantEdit": ".types", "EvaluatorVariantFlags": ".types", "EvaluatorVariantFork": ".types", "EvaluatorVariantResponse": ".types", "EvaluatorVariantsResponse": ".types", "EvaluatorsResponse": ".types", "Event": ".types", "EventQuery": ".types", "EventType": ".types", "EventsQueryResponse": ".types", "ExistenceOperator": ".types", "FetchLegacyAnalyticsRequestNewest": ".legacy", "FetchLegacyAnalyticsRequestOldest": ".legacy", "FetchSimpleTestsetToFileRequestFileType": ".testsets", "FetchTestsetRevisionToFileRequestFileType": ".testsets", "FilteringInput": ".types", "FilteringInputConditionsItem": ".types", "FilteringOutput": ".types", "FilteringOutputConditionsItem": ".types", "Focus": ".types", "Folder": ".types", "FolderCreate": ".types", "FolderEdit": ".types", "FolderIdResponse": ".types", "FolderKind": ".types", "FolderQuery": ".types", "FolderQueryKinds": ".types", "FolderResponse": ".types", "FoldersResponse": ".types", "Format": ".types", "Formatting": ".types", typing.Any: ".types", typing.Any: ".types", "GatewayToolConfig": ".types", "GatewayToolConfigPermission": ".types", "HarnessSessionRecord": ".types", "Header": ".types", "HttpValidationError": ".types", "InviteRequest": ".types", "Invocation": ".types", "InvocationCreate": ".types", "InvocationCreateLinks": ".types", "InvocationEdit": ".types", "InvocationEditLinks": ".types", "InvocationLinkResponse": ".types", "InvocationLinks": ".types", "InvocationQuery": ".types", "InvocationQueryLinks": ".types", "InvocationResponse": ".types", "InvocationsResponse": ".types", "JsonSchemasInput": ".types", "JsonSchemasOutput": ".types", typing.Any: ".types", typing.Any: ".types", "LegacyLifecycleDto": ".types", "ListApiKeysResponse": ".types", "ListOperator": ".types", "ListOptions": ".types", "LogicalOperator": ".types", "MetricSpec": ".types", "MetricType": ".types", "MetricsBucket": ".types", "Mount": ".types", "MountCreate": ".types", "MountCredentials": ".types", "MountCredentialsResponse": ".types", "MountData": ".types", "MountEdit": ".types", "MountFileDeletedResponse": ".types", "MountFileWrittenResponse": ".types", "MountFlags": ".types", "MountFolderCreatedResponse": ".types", "MountQuery": ".types", "MountResponse": ".types", "MountsResponse": ".types", "NumericOperator": ".types", "OTelEventInput": ".types", "OTelEventInputTimestamp": ".types", "OTelEventOutput": ".types", "OTelEventOutputTimestamp": ".types", "OTelHashInput": ".types", "OTelHashOutput": ".types", "OTelLinkInput": ".types", "OTelLinkOutput": ".types", "OTelLinksResponse": ".types", "OTelReferenceInput": ".types", "OTelReferenceOutput": ".types", "OTelSpanKind": ".types", "OTelStatusCode": ".types", "OTelTracingRequest": ".types", "OTelTracingResponse": ".types", "OldAnalyticsResponse": ".types", "Organization": ".types", "OrganizationDetails": ".types", "OrganizationDomainResponse": ".types", "OrganizationProviderResponse": ".types", "OrganizationUpdate": ".types", "Permission": ".types", "PlaygroundBuildKitContext": ".types", "ProjectsResponse": ".types", "QueriesResponse": ".types", "Query": ".types", "QueryApplicationVariantsRequestOrder": ".applications", "QueryCreate": ".types", "QueryEdit": ".types", "QueryEnvironmentRevisionsRequestOrder": ".environments", "QueryEnvironmentVariantsRequestOrder": ".environments", "QueryEnvironmentsRequestOrder": ".environments", "QueryEvaluatorVariantsRequestOrder": ".evaluators", "QueryFlags": ".types", "QueryQueriesRequestOrder": ".queries", "QueryQueryFlags": ".types", "QueryResponse": ".types", "QueryRevision": ".types", "QueryRevisionCommit": ".types", "QueryRevisionCreate": ".types", "QueryRevisionDataInput": ".types", "QueryRevisionDataOutput": ".types", "QueryRevisionEdit": ".types", "QueryRevisionQuery": ".types", "QueryRevisionResponse": ".types", "QueryRevisionsLog": ".types", "QueryRevisionsResponse": ".types", "QuerySpansAnalyticsRequestNewest": ".traces", "QuerySpansAnalyticsRequestOldest": ".traces", "QueryVariant": ".types", "QueryVariantCreate": ".types", "QueryVariantEdit": ".types", "QueryVariantFork": ".types", "QueryVariantQuery": ".types", "QueryVariantResponse": ".types", "QueryVariantsResponse": ".types", "QueryWorkflowRevisionsRequestOrder": ".workflows", "QueryWorkflowVariantsRequestOrder": ".workflows", "QueryWorkflowsRequestOrder": ".workflows", "Reference": ".types", "ReferenceRequestModelInput": ".types", "ReferenceRequestModelOutput": ".types", "RequestType": ".types", "ResolutionInfo": ".types", "ResolvedTool": ".types", "RetrievalInfo": ".types", "SecretDto": ".types", "SecretDtoData": ".types", "SecretKind": ".types", "SecretResponseDto": ".types", "SecretResponseDtoData": ".types", "Selector": ".types", "SessionHeartbeatResponseModel": ".types", "SessionIdsResponse": ".types", "SessionInteraction": ".types", "SessionInteractionData": ".types", "SessionInteractionFlags": ".types", "SessionInteractionKind": ".types", "SessionInteractionQuery": ".types", "SessionInteractionQueryFlags": ".types", "SessionInteractionResponse": ".types", "SessionInteractionStatus": ".types", "SessionInteractionsResponse": ".types", "SessionMount": ".types", "SessionMountQuery": ".types", "SessionMountsResponse": ".types", "SessionRecord": ".types", "SessionRecordResponse": ".types", "SessionRecordsQueryResponse": ".types", "SessionState": ".types", "SessionStateData": ".types", "SessionStateFlags": ".types", "SessionStateResponse": ".types", "SessionStateUpsertRequest": ".types", "SessionStream": ".types", "SessionStreamCommandResponseModel": ".types", "SessionStreamFlags": ".types", "SessionStreamResponseModel": ".types", "SessionStreamsResponseModel": ".types", "SimpleApplication": ".types", "SimpleApplicationAdditionalContext": ".types", "SimpleApplicationCreate": ".types", "SimpleApplicationDataInput": ".types", "SimpleApplicationDataInputHeadersValue": ".types", "SimpleApplicationDataInputRuntime": ".types", "SimpleApplicationDataOutput": ".types", "SimpleApplicationDataOutputHeadersValue": ".types", "SimpleApplicationDataOutputRuntime": ".types", "SimpleApplicationEdit": ".types", "SimpleApplicationFlags": ".types", "SimpleApplicationQuery": ".types", "SimpleApplicationQueryFlags": ".types", "SimpleApplicationResponse": ".types", "SimpleApplicationsResponse": ".types", "SimpleEnvironment": ".types", "SimpleEnvironmentCreate": ".types", "SimpleEnvironmentEdit": ".types", "SimpleEnvironmentQuery": ".types", "SimpleEnvironmentResponse": ".types", "SimpleEnvironmentsResponse": ".types", "SimpleEvaluation": ".types", "SimpleEvaluationCreate": ".types", "SimpleEvaluationData": ".types", "SimpleEvaluationDataApplicationSteps": ".types", "SimpleEvaluationDataApplicationStepsOneValue": ".types", "SimpleEvaluationDataEvaluatorSteps": ".types", "SimpleEvaluationDataEvaluatorStepsOneValue": ".types", "SimpleEvaluationDataQuerySteps": ".types", "SimpleEvaluationDataQueryStepsOneValue": ".types", "SimpleEvaluationDataTestsetSteps": ".types", "SimpleEvaluationDataTestsetStepsOneValue": ".types", "SimpleEvaluationEdit": ".types", "SimpleEvaluationIdResponse": ".types", "SimpleEvaluationQuery": ".types", "SimpleEvaluationResponse": ".types", "SimpleEvaluationsResponse": ".types", "SimpleEvaluator": ".types", "SimpleEvaluatorCreate": ".types", "SimpleEvaluatorDataInput": ".types", "SimpleEvaluatorDataInputHeadersValue": ".types", "SimpleEvaluatorDataInputRuntime": ".types", "SimpleEvaluatorDataOutput": ".types", "SimpleEvaluatorDataOutputHeadersValue": ".types", "SimpleEvaluatorDataOutputRuntime": ".types", "SimpleEvaluatorEdit": ".types", "SimpleEvaluatorFlags": ".types", "SimpleEvaluatorQuery": ".types", "SimpleEvaluatorQueryFlags": ".types", "SimpleEvaluatorResponse": ".types", "SimpleEvaluatorsResponse": ".types", "SimpleQueriesResponse": ".types", "SimpleQuery": ".types", "SimpleQueryCreate": ".types", "SimpleQueryEdit": ".types", "SimpleQueryQuery": ".types", "SimpleQueryResponse": ".types", "SimpleQueue": ".types", "SimpleQueueCreate": ".types", "SimpleQueueData": ".types", "SimpleQueueDataEvaluators": ".types", "SimpleQueueDataEvaluatorsOneValue": ".types", "SimpleQueueIdResponse": ".types", "SimpleQueueIdsResponse": ".types", "SimpleQueueKind": ".types", "SimpleQueueQuery": ".types", "SimpleQueueResponse": ".types", "SimpleQueueScenariosQuery": ".types", "SimpleQueueScenariosResponse": ".types", "SimpleQueueSettings": ".types", "SimpleQueuesResponse": ".types", "SimpleTestset": ".types", "SimpleTestsetCreate": ".types", "SimpleTestsetEdit": ".types", "SimpleTestsetQuery": ".types", "SimpleTestsetResponse": ".types", "SimpleTestsetsResponse": ".types", "SimpleTrace": ".types", "SimpleTraceChannel": ".types", "SimpleTraceCreate": ".types", "SimpleTraceCreateLinks": ".types", "SimpleTraceEdit": ".types", "SimpleTraceEditLinks": ".types", "SimpleTraceKind": ".types", "SimpleTraceLinkResponse": ".types", "SimpleTraceLinks": ".types", "SimpleTraceOrigin": ".types", "SimpleTraceQuery": ".types", "SimpleTraceQueryLinks": ".types", "SimpleTraceReferences": ".types", "SimpleTraceResponse": ".types", "SimpleTracesResponse": ".types", "SimpleWorkflow": ".types", "SimpleWorkflowCreate": ".types", "SimpleWorkflowDataInput": ".types", "SimpleWorkflowDataInputHeadersValue": ".types", "SimpleWorkflowDataInputRuntime": ".types", "SimpleWorkflowDataOutput": ".types", "SimpleWorkflowDataOutputHeadersValue": ".types", "SimpleWorkflowDataOutputRuntime": ".types", "SimpleWorkflowEdit": ".types", "SimpleWorkflowFlags": ".types", "SimpleWorkflowQuery": ".types", "SimpleWorkflowQueryFlags": ".types", "SimpleWorkflowResponse": ".types", "SimpleWorkflowsResponse": ".types", "SpanInput": ".types", "SpanInputEndTime": ".types", "SpanInputStartTime": ".types", "SpanOutput": ".types", "SpanOutputEndTime": ".types", "SpanOutputStartTime": ".types", "SpanResponse": ".types", "SpanType": ".types", "SpansNodeInput": ".types", "SpansNodeInputEndTime": ".types", "SpansNodeInputSpansValue": ".types", "SpansNodeInputStartTime": ".types", "SpansNodeOutput": ".types", "SpansNodeOutputEndTime": ".types", "SpansNodeOutputSpansValue": ".types", "SpansNodeOutputStartTime": ".types", "SpansResponse": ".types", "SpansTreeInput": ".types", "SpansTreeInputSpansValue": ".types", "SpansTreeOutput": ".types", "SpansTreeOutputSpansValue": ".types", "SsoProviderDto": ".types", "SsoProviderInfo": ".types", "SsoProviderSettingsDto": ".types", "SsoProviders": ".types", "StandardProviderDto": ".types", "StandardProviderKind": ".types", "StandardProviderSettingsDto": ".types", "Status": ".types", "StringOperator": ".types", "TestcaseInput": ".types", "TestcaseOutput": ".types", "TestcaseResponse": ".types", "TestcasesResponse": ".types", "Testset": ".types", "TestsetCreate": ".types", "TestsetEdit": ".types", "TestsetFlags": ".types", "TestsetQuery": ".types", "TestsetResponse": ".types", "TestsetRevision": ".types", "TestsetRevisionCommit": ".types", "TestsetRevisionCreate": ".types", "TestsetRevisionDataInput": ".types", "TestsetRevisionDataOutput": ".types", "TestsetRevisionDelta": ".types", "TestsetRevisionDeltaColumns": ".types", "TestsetRevisionDeltaRows": ".types", "TestsetRevisionEdit": ".types", "TestsetRevisionQuery": ".types", "TestsetRevisionResponse": ".types", "TestsetRevisionsLog": ".types", "TestsetRevisionsResponse": ".types", "TestsetVariant": ".types", "TestsetVariantCreate": ".types", "TestsetVariantEdit": ".types", "TestsetVariantFork": ".types", "TestsetVariantQuery": ".types", "TestsetVariantResponse": ".types", "TestsetVariantsResponse": ".types", "TestsetsResponse": ".types", "TextOptions": ".types", "ToolAuthScheme": ".types", "ToolCallData": ".types", "ToolCallFunction": ".types", "ToolCallResponse": ".types", "ToolCatalogAction": ".types", "ToolCatalogActionDetails": ".types", "ToolCatalogActionResponse": ".types", "ToolCatalogActionResponseAction": ".types", "ToolCatalogActionsResponse": ".types", "ToolCatalogActionsResponseActionsItem": ".types", "ToolCatalogCategoriesResponse": ".types", "ToolCatalogCategory": ".types", "ToolCatalogIntegration": ".types", "ToolCatalogIntegrationDetails": ".types", "ToolCatalogIntegrationResponse": ".types", "ToolCatalogIntegrationResponseIntegration": ".types", "ToolCatalogIntegrationsResponse": ".types", "ToolCatalogIntegrationsResponseIntegrationsItem": ".types", "ToolCatalogProvider": ".types", "ToolCatalogProviderDetails": ".types", "ToolCatalogProviderResponse": ".types", "ToolCatalogProviderResponseProvider": ".types", "ToolCatalogProvidersResponse": ".types", "ToolCatalogProvidersResponseProvidersItem": ".types", "ToolConnection": ".types", "ToolConnectionCreate": ".types", "ToolConnectionCreateData": ".types", "ToolConnectionResponse": ".types", "ToolConnectionState": ".types", "ToolConnectionStatus": ".types", "ToolConnectionsResponse": ".types", "ToolProviderKind": ".types", "ToolResolveRequestToolsItem": ".tools", "ToolResolveRequestToolsItem_Builtin": ".tools", "ToolResolveRequestToolsItem_Gateway": ".tools", "ToolResolveResponse": ".types", "ToolResult": ".types", "ToolResultData": ".types", "TraceIdResponse": ".types", "TraceIdsResponse": ".types", "TraceInput": ".types", "TraceInputSpansValue": ".types", "TraceOutput": ".types", "TraceOutputSpansValue": ".types", "TraceRequest": ".types", "TraceResponse": ".types", "TraceType": ".types", "TracesRequest": ".types", "TracesResponse": ".types", "TracingQuery": ".types", "TriggerAuthScheme": ".types", "TriggerCapabilitiesResult": ".types", "TriggerCapability": ".types", "TriggerCapabilityConnection": ".types", "TriggerCatalogEvent": ".types", "TriggerCatalogEventDetails": ".types", "TriggerCatalogEventResponse": ".types", "TriggerCatalogEventsResponse": ".types", "TriggerCatalogIntegration": ".types", "TriggerCatalogIntegrationResponse": ".types", "TriggerCatalogIntegrationsResponse": ".types", "TriggerCatalogProvider": ".types", "TriggerCatalogProviderResponse": ".types", "TriggerCatalogProvidersResponse": ".types", "TriggerConnectAffordance": ".types", "TriggerConnection": ".types", "TriggerConnectionCreate": ".types", "TriggerConnectionCreateData": ".types", "TriggerConnectionRequirement": ".types", "TriggerConnectionResponse": ".types", "TriggerConnectionStatus": ".types", "TriggerConnectionsResponse": ".types", "TriggerDeliveriesResponse": ".types", "TriggerDelivery": ".types", "TriggerDeliveryData": ".types", "TriggerDeliveryQuery": ".types", "TriggerDeliveryResponse": ".types", "TriggerDiscoveryConnectionState": ".types", "TriggerDiscoveryGuidance": ".types", "TriggerEventAck": ".types", "TriggerProviderKind": ".types", "TriggerSchedule": ".types", "TriggerScheduleCreate": ".types", "TriggerScheduleData": ".types", "TriggerScheduleDataInputsFields": ".types", "TriggerScheduleEdit": ".types", "TriggerScheduleFlags": ".types", "TriggerScheduleQuery": ".types", "TriggerScheduleResponse": ".types", "TriggerSchedulesResponse": ".types", "TriggerSubscription": ".types", "TriggerSubscriptionCreate": ".types", "TriggerSubscriptionCreateRequest": ".types", "TriggerSubscriptionData": ".types", "TriggerSubscriptionDataInputsFields": ".types", "TriggerSubscriptionEdit": ".types", "TriggerSubscriptionFlags": ".types", "TriggerSubscriptionQuery": ".types", "TriggerSubscriptionResponse": ".types", "TriggerSubscriptionsResponse": ".types", "UnprocessableEntityError": ".errors", "UserIdsResponse": ".types", "ValidationError": ".types", "ValidationErrorLocItem": ".types", "WebhookDeliveriesResponse": ".types", "WebhookDelivery": ".types", "WebhookDeliveryCreate": ".types", "WebhookDeliveryData": ".types", "WebhookDeliveryQuery": ".types", "WebhookDeliveryResponse": ".types", "WebhookDeliveryResponseInfo": ".types", "WebhookEventType": ".types", "WebhookProviderDto": ".types", "WebhookProviderSettingsDto": ".types", "WebhookSubscription": ".types", "WebhookSubscriptionCreate": ".types", "WebhookSubscriptionData": ".types", "WebhookSubscriptionDataAuthMode": ".types", "WebhookSubscriptionEdit": ".types", "WebhookSubscriptionFlags": ".types", "WebhookSubscriptionQuery": ".types", "WebhookSubscriptionResponse": ".types", "WebhookSubscriptionTestRequestSubscription": ".webhooks", "WebhookSubscriptionsResponse": ".types", "Windowing": ".types", "WindowingOrder": ".types", "Workflow": ".types", "WorkflowArtifactFlags": ".types", "WorkflowCatalogFlags": ".types", "WorkflowCatalogHarness": ".types", "WorkflowCatalogHarnessResponse": ".types", "WorkflowCatalogHarnessesResponse": ".types", "WorkflowCatalogPreset": ".types", "WorkflowCatalogPresetResponse": ".types", "WorkflowCatalogPresetsResponse": ".types", "WorkflowCatalogTemplate": ".types", "WorkflowCatalogTemplateResponse": ".types", "WorkflowCatalogTemplatesResponse": ".types", "WorkflowCatalogType": ".types", "WorkflowCatalogTypeResponse": ".types", "WorkflowCatalogTypesResponse": ".types", "WorkflowCreate": ".types", "WorkflowEdit": ".types", "WorkflowFlags": ".types", "WorkflowResponse": ".types", "WorkflowRevisionCommit": ".types", "WorkflowRevisionCreate": ".types", "WorkflowRevisionDataInput": ".types", "WorkflowRevisionDataInputHeadersValue": ".types", "WorkflowRevisionDataInputRuntime": ".types", "WorkflowRevisionDataOutput": ".types", "WorkflowRevisionDataOutputHeadersValue": ".types", "WorkflowRevisionDataOutputRuntime": ".types", "WorkflowRevisionDelta": ".types", "WorkflowRevisionEdit": ".types", "WorkflowRevisionFlags": ".types", "WorkflowRevisionInput": ".types", "WorkflowRevisionOutput": ".types", "WorkflowRevisionResolveResponse": ".types", "WorkflowRevisionResponse": ".types", "WorkflowRevisionsLog": ".types", "WorkflowRevisionsResponse": ".types", "WorkflowVariant": ".types", "WorkflowVariantCreate": ".types", "WorkflowVariantEdit": ".types", "WorkflowVariantFlags": ".types", "WorkflowVariantFork": ".types", "WorkflowVariantResponse": ".types", "WorkflowVariantsResponse": ".types", "WorkflowsResponse": ".types", "Workspace": ".types", "WorkspaceMemberResponse": ".types", "WorkspacePermission": ".types", "WorkspaceResponse": ".types", "access": ".access", "annotations": ".annotations", "applications": ".applications", "billing": ".billing", "environments": ".environments", "evaluations": ".evaluations", "evaluators": ".evaluators", "events": ".events", "folders": ".folders", "invocations": ".invocations", "keys": ".keys", "legacy": ".legacy", "mounts": ".mounts", "organizations": ".organizations", "projects": ".projects", "queries": ".queries", "secrets": ".secrets", "sessions": ".sessions", "status": ".status", "testcases": ".testcases", "testsets": ".testsets", "tools": ".tools", "traces": ".traces", "triggers": ".triggers", "users": ".users", "webhooks": ".webhooks", "workflows": ".workflows", "workspaces": ".workspaces"} +_dynamic_imports: typing.Dict[str, str] = {"AdminAccountCreateOptions": ".types", "AdminAccountRead": ".types", "AdminAccountsCreate": ".types", "AdminAccountsDelete": ".types", "AdminAccountsDeleteTarget": ".types", "AdminAccountsResponse": ".types", "AdminApiKeyCreate": ".types", "AdminApiKeyResponse": ".types", "AdminDeleteResponse": ".types", "AdminDeletedEntities": ".types", "AdminDeletedEntity": ".types", "AdminOrganizationCreate": ".types", "AdminOrganizationMembershipCreate": ".types", "AdminOrganizationMembershipRead": ".types", "AdminOrganizationRead": ".types", "AdminProjectCreate": ".types", "AdminProjectMembershipCreate": ".types", "AdminProjectMembershipRead": ".types", "AdminProjectRead": ".types", "AdminSimpleAccountCreate": ".types", "AdminSimpleAccountDeleteEntry": ".types", "AdminSimpleAccountRead": ".types", "AdminSimpleAccountsApiKeysCreate": ".types", "AdminSimpleAccountsCreate": ".types", "AdminSimpleAccountsDelete": ".types", "AdminSimpleAccountsOrganizationsCreate": ".types", "AdminSimpleAccountsOrganizationsMembershipsCreate": ".types", "AdminSimpleAccountsOrganizationsTransferOwnership": ".types", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjects": ".types", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjectsZero": ".types", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspaces": ".types", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspacesZero": ".types", "AdminSimpleAccountsOrganizationsTransferOwnershipResponse": ".types", "AdminSimpleAccountsProjectsCreate": ".types", "AdminSimpleAccountsProjectsMembershipsCreate": ".types", "AdminSimpleAccountsResponse": ".types", "AdminSimpleAccountsUsersCreate": ".types", "AdminSimpleAccountsUsersIdentitiesCreate": ".types", "AdminSimpleAccountsUsersResetPassword": ".types", "AdminSimpleAccountsWorkspacesCreate": ".types", "AdminSimpleAccountsWorkspacesMembershipsCreate": ".types", "AdminStructuredError": ".types", "AdminSubscriptionCreate": ".types", "AdminSubscriptionRead": ".types", "AdminUserCreate": ".types", "AdminUserIdentityCreate": ".types", "AdminUserIdentityRead": ".types", "AdminUserIdentityReadStatus": ".types", "AdminUserRead": ".types", "AdminWorkspaceCreate": ".types", "AdminWorkspaceMembershipCreate": ".types", "AdminWorkspaceMembershipRead": ".types", "AdminWorkspaceRead": ".types", "AgentTemplateOverlay": ".types", "AgentaApi": ".client", "AgentaApiEnvironment": ".environment", "Analytics": ".types", "AnalyticsResponse": ".types", "Annotation": ".types", "AnnotationCreate": ".types", "AnnotationCreateLinks": ".types", "AnnotationEdit": ".types", "AnnotationEditLinks": ".types", "AnnotationLinkResponse": ".types", "AnnotationLinks": ".types", "AnnotationQuery": ".types", "AnnotationQueryLinks": ".types", "AnnotationResponse": ".types", "AnnotationsResponse": ".types", "Application": ".types", "ApplicationArtifactFlags": ".types", "ApplicationArtifactQueryFlags": ".types", "ApplicationCatalogPreset": ".types", "ApplicationCatalogPresetResponse": ".types", "ApplicationCatalogPresetsResponse": ".types", "ApplicationCatalogTemplate": ".types", "ApplicationCatalogTemplateResponse": ".types", "ApplicationCatalogTemplatesResponse": ".types", "ApplicationCatalogType": ".types", "ApplicationCatalogTypesResponse": ".types", "ApplicationCreate": ".types", "ApplicationEdit": ".types", "ApplicationFlags": ".types", "ApplicationQuery": ".types", "ApplicationResponse": ".types", "ApplicationRevisionCommit": ".types", "ApplicationRevisionCreate": ".types", "ApplicationRevisionDataInput": ".types", "ApplicationRevisionDataInputHeadersValue": ".types", "ApplicationRevisionDataInputRuntime": ".types", "ApplicationRevisionDataOutput": ".types", "ApplicationRevisionDataOutputHeadersValue": ".types", "ApplicationRevisionDataOutputRuntime": ".types", "ApplicationRevisionEdit": ".types", "ApplicationRevisionFlags": ".types", "ApplicationRevisionInput": ".types", "ApplicationRevisionOutput": ".types", "ApplicationRevisionQuery": ".types", "ApplicationRevisionQueryFlags": ".types", "ApplicationRevisionResolveResponse": ".types", "ApplicationRevisionResponse": ".types", "ApplicationRevisionsLog": ".types", "ApplicationRevisionsResponse": ".types", "ApplicationVariant": ".types", "ApplicationVariantCreate": ".types", "ApplicationVariantEdit": ".types", "ApplicationVariantFlags": ".types", "ApplicationVariantFork": ".types", "ApplicationVariantResponse": ".types", "ApplicationVariantsResponse": ".types", "ApplicationsResponse": ".types", "AsyncAgentaApi": ".client", "BodyConfigsFetchVariantsConfigsFetchPost": ".types", "Bucket": ".types", "BuiltinToolConfig": ".types", "BuiltinToolConfigPermission": ".types", "CapabilitiesResult": ".types", "Capability": ".types", "CapabilityConnection": ".types", "CapabilityGuidance": ".types", "CollectStatusResponse": ".types", "CommandMode": ".types", "ComparisonOperator": ".types", "Condition": ".types", "ConditionOperator": ".types", "ConditionOptions": ".types", "ConditionValue": ".types", "ConfigResponseModel": ".types", "ConnectAffordance": ".types", "ConnectionRequirement": ".types", "CreateSimpleTestsetFromFileRequestFileType": ".testsets", "CreateTestsetRevisionFromFileRequestFileType": ".testsets", "CustomModelSettingsDto": ".types", "CustomProviderDto": ".types", "CustomProviderKind": ".types", "CustomProviderSettingsDto": ".types", "CustomSecretDto": ".types", "CustomSecretFormat": ".types", "CustomSecretSettingsDto": ".types", "CustomSecretSettingsDtoContent": ".types", "CustomSecretSettingsDtoContentOneValue": ".types", "DictOperator": ".types", "DiscoverResponse": ".types", "DiscoverResponseMethodsValue": ".types", "DiscoveredAlternative": ".types", "DiscoveredTool": ".types", "DiscoveredToolType": ".types", "DiscoveredTriggerAlternative": ".types", "DiscoveredTriggerEvent": ".types", "DiscoveredTriggerEventType": ".types", "EditSimpleTestsetFromFileRequestFileType": ".testsets", "EntityRef": ".types", "Environment": ".types", "EnvironmentCreate": ".types", "EnvironmentEdit": ".types", "EnvironmentFlags": ".types", "EnvironmentQueryFlags": ".types", "EnvironmentResponse": ".types", "EnvironmentRevisionCommit": ".types", "EnvironmentRevisionCreate": ".types", "EnvironmentRevisionData": ".types", "EnvironmentRevisionDelta": ".types", "EnvironmentRevisionEdit": ".types", "EnvironmentRevisionInput": ".types", "EnvironmentRevisionOutput": ".types", "EnvironmentRevisionResolveResponse": ".types", "EnvironmentRevisionResponse": ".types", "EnvironmentRevisionsLog": ".types", "EnvironmentRevisionsResponse": ".types", "EnvironmentVariant": ".types", "EnvironmentVariantCreate": ".types", "EnvironmentVariantEdit": ".types", "EnvironmentVariantFork": ".types", "EnvironmentVariantResponse": ".types", "EnvironmentVariantsResponse": ".types", "EnvironmentsResponse": ".types", "ErrorPolicy": ".types", "EvaluationMetrics": ".types", "EvaluationMetricsCreate": ".types", "EvaluationMetricsIdsResponse": ".types", "EvaluationMetricsQuery": ".types", "EvaluationMetricsQueryScenarioIds": ".types", "EvaluationMetricsQueryTimestamps": ".types", "EvaluationMetricsRefresh": ".types", "EvaluationMetricsResponse": ".types", "EvaluationMetricsSetRequest": ".types", "EvaluationQueue": ".types", "EvaluationQueueCreate": ".types", "EvaluationQueueData": ".types", "EvaluationQueueEdit": ".types", "EvaluationQueueFlags": ".types", "EvaluationQueueIdResponse": ".types", "EvaluationQueueIdsResponse": ".types", "EvaluationQueueQuery": ".types", "EvaluationQueueQueryFlags": ".types", "EvaluationQueueResponse": ".types", "EvaluationQueueScenariosQuery": ".types", "EvaluationQueuesResponse": ".types", "EvaluationResult": ".types", "EvaluationResultCreate": ".types", "EvaluationResultIdResponse": ".types", "EvaluationResultIdsResponse": ".types", "EvaluationResultQuery": ".types", "EvaluationResultResponse": ".types", "EvaluationResultsResponse": ".types", "EvaluationResultsSetRequest": ".types", "EvaluationRun": ".types", "EvaluationRunCreate": ".types", "EvaluationRunDataConcurrency": ".types", "EvaluationRunDataInput": ".types", "EvaluationRunDataMapping": ".types", "EvaluationRunDataMappingColumn": ".types", "EvaluationRunDataMappingStep": ".types", "EvaluationRunDataOutput": ".types", "EvaluationRunDataStepInput": ".types", "EvaluationRunDataStepInputKey": ".types", "EvaluationRunDataStepInputOrigin": ".types", "EvaluationRunDataStepInputType": ".types", "EvaluationRunDataStepOutput": ".types", "EvaluationRunDataStepOutputOrigin": ".types", "EvaluationRunDataStepOutputType": ".types", "EvaluationRunEdit": ".types", "EvaluationRunFlags": ".types", "EvaluationRunIdResponse": ".types", "EvaluationRunIdsRequest": ".types", "EvaluationRunIdsResponse": ".types", "EvaluationRunQuery": ".types", "EvaluationRunQueryFlags": ".types", "EvaluationRunResponse": ".types", "EvaluationRunsResponse": ".types", "EvaluationScenario": ".types", "EvaluationScenarioCreate": ".types", "EvaluationScenarioEdit": ".types", "EvaluationScenarioIdResponse": ".types", "EvaluationScenarioIdsResponse": ".types", "EvaluationScenarioQuery": ".types", "EvaluationScenarioResponse": ".types", "EvaluationScenariosResponse": ".types", "EvaluationStatus": ".types", "Evaluator": ".types", "EvaluatorArtifactFlags": ".types", "EvaluatorArtifactQueryFlags": ".types", "EvaluatorCatalogPreset": ".types", "EvaluatorCatalogPresetResponse": ".types", "EvaluatorCatalogPresetsResponse": ".types", "EvaluatorCatalogTemplate": ".types", "EvaluatorCatalogTemplateResponse": ".types", "EvaluatorCatalogTemplatesResponse": ".types", "EvaluatorCatalogType": ".types", "EvaluatorCatalogTypesResponse": ".types", "EvaluatorCreate": ".types", "EvaluatorEdit": ".types", "EvaluatorFlags": ".types", "EvaluatorQuery": ".types", "EvaluatorResponse": ".types", "EvaluatorRevisionCommit": ".types", "EvaluatorRevisionCreate": ".types", "EvaluatorRevisionDataInput": ".types", "EvaluatorRevisionDataInputHeadersValue": ".types", "EvaluatorRevisionDataInputRuntime": ".types", "EvaluatorRevisionDataOutput": ".types", "EvaluatorRevisionDataOutputHeadersValue": ".types", "EvaluatorRevisionDataOutputRuntime": ".types", "EvaluatorRevisionEdit": ".types", "EvaluatorRevisionFlags": ".types", "EvaluatorRevisionInput": ".types", "EvaluatorRevisionOutput": ".types", "EvaluatorRevisionQuery": ".types", "EvaluatorRevisionQueryFlags": ".types", "EvaluatorRevisionResolveResponse": ".types", "EvaluatorRevisionResponse": ".types", "EvaluatorRevisionsLog": ".types", "EvaluatorRevisionsResponse": ".types", "EvaluatorTemplate": ".types", "EvaluatorTemplatesResponse": ".types", "EvaluatorVariant": ".types", "EvaluatorVariantCreate": ".types", "EvaluatorVariantEdit": ".types", "EvaluatorVariantFlags": ".types", "EvaluatorVariantFork": ".types", "EvaluatorVariantResponse": ".types", "EvaluatorVariantsResponse": ".types", "EvaluatorsResponse": ".types", "Event": ".types", "EventQuery": ".types", "EventType": ".types", "EventsQueryResponse": ".types", "ExistenceOperator": ".types", "FetchLegacyAnalyticsRequestNewest": ".legacy", "FetchLegacyAnalyticsRequestOldest": ".legacy", "FetchSimpleTestsetToFileRequestFileType": ".testsets", "FetchTestsetRevisionToFileRequestFileType": ".testsets", "FilteringInput": ".types", "FilteringInputConditionsItem": ".types", "FilteringOutput": ".types", "FilteringOutputConditionsItem": ".types", "Focus": ".types", "Folder": ".types", "FolderCreate": ".types", "FolderEdit": ".types", "FolderIdResponse": ".types", "FolderKind": ".types", "FolderQuery": ".types", "FolderQueryKinds": ".types", "FolderResponse": ".types", "FoldersResponse": ".types", "Format": ".types", "Formatting": ".types", typing.Any: ".types", typing.Any: ".types", "GatewayToolConfig": ".types", "GatewayToolConfigPermission": ".types", "HarnessKind": ".types", "Header": ".types", "HttpValidationError": ".types", "InviteRequest": ".types", "Invocation": ".types", "InvocationCreate": ".types", "InvocationCreateLinks": ".types", "InvocationEdit": ".types", "InvocationEditLinks": ".types", "InvocationLinkResponse": ".types", "InvocationLinks": ".types", "InvocationQuery": ".types", "InvocationQueryLinks": ".types", "InvocationResponse": ".types", "InvocationsResponse": ".types", "JsonSchemasInput": ".types", "JsonSchemasOutput": ".types", typing.Any: ".types", typing.Any: ".types", "LegacyLifecycleDto": ".types", "ListApiKeysResponse": ".types", "ListOperator": ".types", "ListOptions": ".types", "LogicalOperator": ".types", "MetricSpec": ".types", "MetricType": ".types", "MetricsBucket": ".types", "Mount": ".types", "MountCreate": ".types", "MountCredentials": ".types", "MountCredentialsResponse": ".types", "MountData": ".types", "MountEdit": ".types", "MountFileDeletedResponse": ".types", "MountFileWrittenResponse": ".types", "MountFlags": ".types", "MountFolderCreatedResponse": ".types", "MountQuery": ".types", "MountResponse": ".types", "MountsResponse": ".types", "NumericOperator": ".types", "OTelEventInput": ".types", "OTelEventInputTimestamp": ".types", "OTelEventOutput": ".types", "OTelEventOutputTimestamp": ".types", "OTelHashInput": ".types", "OTelHashOutput": ".types", "OTelLinkInput": ".types", "OTelLinkOutput": ".types", "OTelLinksResponse": ".types", "OTelReferenceInput": ".types", "OTelReferenceOutput": ".types", "OTelSpanKind": ".types", "OTelStatusCode": ".types", "OTelTracingRequest": ".types", "OTelTracingResponse": ".types", "OldAnalyticsResponse": ".types", "Organization": ".types", "OrganizationDetails": ".types", "OrganizationDomainResponse": ".types", "OrganizationProviderResponse": ".types", "OrganizationUpdate": ".types", "Permission": ".types", "PlaygroundBuildKitContext": ".types", "ProjectsResponse": ".types", "QueriesResponse": ".types", "Query": ".types", "QueryApplicationVariantsRequestOrder": ".applications", "QueryCreate": ".types", "QueryEdit": ".types", "QueryEnvironmentRevisionsRequestOrder": ".environments", "QueryEnvironmentVariantsRequestOrder": ".environments", "QueryEnvironmentsRequestOrder": ".environments", "QueryEvaluatorVariantsRequestOrder": ".evaluators", "QueryFlags": ".types", "QueryQueriesRequestOrder": ".queries", "QueryQueryFlags": ".types", "QueryResponse": ".types", "QueryRevision": ".types", "QueryRevisionCommit": ".types", "QueryRevisionCreate": ".types", "QueryRevisionDataInput": ".types", "QueryRevisionDataOutput": ".types", "QueryRevisionEdit": ".types", "QueryRevisionQuery": ".types", "QueryRevisionResponse": ".types", "QueryRevisionsLog": ".types", "QueryRevisionsResponse": ".types", "QuerySpansAnalyticsRequestNewest": ".traces", "QuerySpansAnalyticsRequestOldest": ".traces", "QueryVariant": ".types", "QueryVariantCreate": ".types", "QueryVariantEdit": ".types", "QueryVariantFork": ".types", "QueryVariantQuery": ".types", "QueryVariantResponse": ".types", "QueryVariantsResponse": ".types", "QueryWorkflowRevisionsRequestOrder": ".workflows", "QueryWorkflowVariantsRequestOrder": ".workflows", "QueryWorkflowsRequestOrder": ".workflows", "Reference": ".types", "ReferenceRequestModelInput": ".types", "ReferenceRequestModelOutput": ".types", "RequestType": ".types", "ResolutionInfo": ".types", "ResolvedTool": ".types", "RetrievalInfo": ".types", "SecretDto": ".types", "SecretDtoData": ".types", "SecretKind": ".types", "SecretResponseDto": ".types", "SecretResponseDtoData": ".types", "Selector": ".types", "SessionHeartbeatResult": ".types", "SessionIdsResponse": ".types", "SessionInteraction": ".types", "SessionInteractionData": ".types", "SessionInteractionFlags": ".types", "SessionInteractionKind": ".types", "SessionInteractionQuery": ".types", "SessionInteractionQueryFlags": ".types", "SessionInteractionResponse": ".types", "SessionInteractionStatus": ".types", "SessionInteractionsResponse": ".types", "SessionMount": ".types", "SessionMountQuery": ".types", "SessionMountsResponse": ".types", "SessionRecord": ".types", "SessionRecordResponse": ".types", "SessionRecordsQueryResponse": ".types", "SessionResponse": ".types", "SessionStream": ".types", "SessionStreamCommandResponse": ".types", "SessionStreamFlags": ".types", "SessionStreamHeaderEdit": ".types", "SessionStreamResponse": ".types", "SessionStreamsResponse": ".types", "SessionTurn": ".types", "SessionTurnQuery": ".types", "SessionTurnResponse": ".types", "SessionTurnsResponse": ".types", "SessionsResponse": ".types", "SimpleApplication": ".types", "SimpleApplicationAdditionalContext": ".types", "SimpleApplicationCreate": ".types", "SimpleApplicationDataInput": ".types", "SimpleApplicationDataInputHeadersValue": ".types", "SimpleApplicationDataInputRuntime": ".types", "SimpleApplicationDataOutput": ".types", "SimpleApplicationDataOutputHeadersValue": ".types", "SimpleApplicationDataOutputRuntime": ".types", "SimpleApplicationEdit": ".types", "SimpleApplicationFlags": ".types", "SimpleApplicationQuery": ".types", "SimpleApplicationQueryFlags": ".types", "SimpleApplicationResponse": ".types", "SimpleApplicationsResponse": ".types", "SimpleEnvironment": ".types", "SimpleEnvironmentCreate": ".types", "SimpleEnvironmentEdit": ".types", "SimpleEnvironmentQuery": ".types", "SimpleEnvironmentResponse": ".types", "SimpleEnvironmentsResponse": ".types", "SimpleEvaluation": ".types", "SimpleEvaluationCreate": ".types", "SimpleEvaluationData": ".types", "SimpleEvaluationDataApplicationSteps": ".types", "SimpleEvaluationDataApplicationStepsOneValue": ".types", "SimpleEvaluationDataEvaluatorSteps": ".types", "SimpleEvaluationDataEvaluatorStepsOneValue": ".types", "SimpleEvaluationDataQuerySteps": ".types", "SimpleEvaluationDataQueryStepsOneValue": ".types", "SimpleEvaluationDataTestsetSteps": ".types", "SimpleEvaluationDataTestsetStepsOneValue": ".types", "SimpleEvaluationEdit": ".types", "SimpleEvaluationIdResponse": ".types", "SimpleEvaluationQuery": ".types", "SimpleEvaluationResponse": ".types", "SimpleEvaluationsResponse": ".types", "SimpleEvaluator": ".types", "SimpleEvaluatorCreate": ".types", "SimpleEvaluatorDataInput": ".types", "SimpleEvaluatorDataInputHeadersValue": ".types", "SimpleEvaluatorDataInputRuntime": ".types", "SimpleEvaluatorDataOutput": ".types", "SimpleEvaluatorDataOutputHeadersValue": ".types", "SimpleEvaluatorDataOutputRuntime": ".types", "SimpleEvaluatorEdit": ".types", "SimpleEvaluatorFlags": ".types", "SimpleEvaluatorQuery": ".types", "SimpleEvaluatorQueryFlags": ".types", "SimpleEvaluatorResponse": ".types", "SimpleEvaluatorsResponse": ".types", "SimpleQueriesResponse": ".types", "SimpleQuery": ".types", "SimpleQueryCreate": ".types", "SimpleQueryEdit": ".types", "SimpleQueryQuery": ".types", "SimpleQueryResponse": ".types", "SimpleQueue": ".types", "SimpleQueueCreate": ".types", "SimpleQueueData": ".types", "SimpleQueueDataEvaluators": ".types", "SimpleQueueDataEvaluatorsOneValue": ".types", "SimpleQueueIdResponse": ".types", "SimpleQueueIdsResponse": ".types", "SimpleQueueKind": ".types", "SimpleQueueQuery": ".types", "SimpleQueueResponse": ".types", "SimpleQueueScenariosQuery": ".types", "SimpleQueueScenariosResponse": ".types", "SimpleQueueSettings": ".types", "SimpleQueuesResponse": ".types", "SimpleTestset": ".types", "SimpleTestsetCreate": ".types", "SimpleTestsetEdit": ".types", "SimpleTestsetQuery": ".types", "SimpleTestsetResponse": ".types", "SimpleTestsetsResponse": ".types", "SimpleTrace": ".types", "SimpleTraceChannel": ".types", "SimpleTraceCreate": ".types", "SimpleTraceCreateLinks": ".types", "SimpleTraceEdit": ".types", "SimpleTraceEditLinks": ".types", "SimpleTraceKind": ".types", "SimpleTraceLinkResponse": ".types", "SimpleTraceLinks": ".types", "SimpleTraceOrigin": ".types", "SimpleTraceQuery": ".types", "SimpleTraceQueryLinks": ".types", "SimpleTraceReferences": ".types", "SimpleTraceResponse": ".types", "SimpleTracesResponse": ".types", "SimpleWorkflow": ".types", "SimpleWorkflowCreate": ".types", "SimpleWorkflowDataInput": ".types", "SimpleWorkflowDataInputHeadersValue": ".types", "SimpleWorkflowDataInputRuntime": ".types", "SimpleWorkflowDataOutput": ".types", "SimpleWorkflowDataOutputHeadersValue": ".types", "SimpleWorkflowDataOutputRuntime": ".types", "SimpleWorkflowEdit": ".types", "SimpleWorkflowFlags": ".types", "SimpleWorkflowQuery": ".types", "SimpleWorkflowQueryFlags": ".types", "SimpleWorkflowResponse": ".types", "SimpleWorkflowsResponse": ".types", "SpanInput": ".types", "SpanInputEndTime": ".types", "SpanInputStartTime": ".types", "SpanOutput": ".types", "SpanOutputEndTime": ".types", "SpanOutputStartTime": ".types", "SpanResponse": ".types", "SpanType": ".types", "SpansNodeInput": ".types", "SpansNodeInputEndTime": ".types", "SpansNodeInputSpansValue": ".types", "SpansNodeInputStartTime": ".types", "SpansNodeOutput": ".types", "SpansNodeOutputEndTime": ".types", "SpansNodeOutputSpansValue": ".types", "SpansNodeOutputStartTime": ".types", "SpansResponse": ".types", "SpansTreeInput": ".types", "SpansTreeInputSpansValue": ".types", "SpansTreeOutput": ".types", "SpansTreeOutputSpansValue": ".types", "SsoProviderDto": ".types", "SsoProviderInfo": ".types", "SsoProviderSettingsDto": ".types", "SsoProviders": ".types", "StandardProviderDto": ".types", "StandardProviderKind": ".types", "StandardProviderSettingsDto": ".types", "Status": ".types", "StringOperator": ".types", "TestcaseInput": ".types", "TestcaseOutput": ".types", "TestcaseResponse": ".types", "TestcasesResponse": ".types", "Testset": ".types", "TestsetCreate": ".types", "TestsetEdit": ".types", "TestsetFlags": ".types", "TestsetQuery": ".types", "TestsetResponse": ".types", "TestsetRevision": ".types", "TestsetRevisionCommit": ".types", "TestsetRevisionCreate": ".types", "TestsetRevisionDataInput": ".types", "TestsetRevisionDataOutput": ".types", "TestsetRevisionDelta": ".types", "TestsetRevisionDeltaColumns": ".types", "TestsetRevisionDeltaRows": ".types", "TestsetRevisionEdit": ".types", "TestsetRevisionQuery": ".types", "TestsetRevisionResponse": ".types", "TestsetRevisionsLog": ".types", "TestsetRevisionsResponse": ".types", "TestsetVariant": ".types", "TestsetVariantCreate": ".types", "TestsetVariantEdit": ".types", "TestsetVariantFork": ".types", "TestsetVariantQuery": ".types", "TestsetVariantResponse": ".types", "TestsetVariantsResponse": ".types", "TestsetsResponse": ".types", "TextOptions": ".types", "ToolAuthScheme": ".types", "ToolCallData": ".types", "ToolCallFunction": ".types", "ToolCallResponse": ".types", "ToolCatalogAction": ".types", "ToolCatalogActionDetails": ".types", "ToolCatalogActionResponse": ".types", "ToolCatalogActionResponseAction": ".types", "ToolCatalogActionsResponse": ".types", "ToolCatalogActionsResponseActionsItem": ".types", "ToolCatalogCategoriesResponse": ".types", "ToolCatalogCategory": ".types", "ToolCatalogIntegration": ".types", "ToolCatalogIntegrationDetails": ".types", "ToolCatalogIntegrationResponse": ".types", "ToolCatalogIntegrationResponseIntegration": ".types", "ToolCatalogIntegrationsResponse": ".types", "ToolCatalogIntegrationsResponseIntegrationsItem": ".types", "ToolCatalogProvider": ".types", "ToolCatalogProviderDetails": ".types", "ToolCatalogProviderResponse": ".types", "ToolCatalogProviderResponseProvider": ".types", "ToolCatalogProvidersResponse": ".types", "ToolCatalogProvidersResponseProvidersItem": ".types", "ToolConnection": ".types", "ToolConnectionCreate": ".types", "ToolConnectionCreateData": ".types", "ToolConnectionResponse": ".types", "ToolConnectionState": ".types", "ToolConnectionStatus": ".types", "ToolConnectionsResponse": ".types", "ToolProviderKind": ".types", "ToolResolveRequestToolsItem": ".tools", "ToolResolveRequestToolsItem_Builtin": ".tools", "ToolResolveRequestToolsItem_Gateway": ".tools", "ToolResolveResponse": ".types", "ToolResult": ".types", "ToolResultData": ".types", "TraceIdResponse": ".types", "TraceIdsResponse": ".types", "TraceInput": ".types", "TraceInputSpansValue": ".types", "TraceOutput": ".types", "TraceOutputSpansValue": ".types", "TraceRequest": ".types", "TraceResponse": ".types", "TraceType": ".types", "TracesRequest": ".types", "TracesResponse": ".types", "TracingQuery": ".types", "TriggerAuthScheme": ".types", "TriggerCapabilitiesResult": ".types", "TriggerCapability": ".types", "TriggerCapabilityConnection": ".types", "TriggerCatalogEvent": ".types", "TriggerCatalogEventDetails": ".types", "TriggerCatalogEventResponse": ".types", "TriggerCatalogEventsResponse": ".types", "TriggerCatalogIntegration": ".types", "TriggerCatalogIntegrationResponse": ".types", "TriggerCatalogIntegrationsResponse": ".types", "TriggerCatalogProvider": ".types", "TriggerCatalogProviderResponse": ".types", "TriggerCatalogProvidersResponse": ".types", "TriggerConnectAffordance": ".types", "TriggerConnection": ".types", "TriggerConnectionCreate": ".types", "TriggerConnectionCreateData": ".types", "TriggerConnectionRequirement": ".types", "TriggerConnectionResponse": ".types", "TriggerConnectionStatus": ".types", "TriggerConnectionsResponse": ".types", "TriggerDeliveriesResponse": ".types", "TriggerDelivery": ".types", "TriggerDeliveryData": ".types", "TriggerDeliveryQuery": ".types", "TriggerDeliveryResponse": ".types", "TriggerDiscoveryConnectionState": ".types", "TriggerDiscoveryGuidance": ".types", "TriggerEventAck": ".types", "TriggerProviderKind": ".types", "TriggerSchedule": ".types", "TriggerScheduleCreate": ".types", "TriggerScheduleData": ".types", "TriggerScheduleDataInputsFields": ".types", "TriggerScheduleEdit": ".types", "TriggerScheduleFlags": ".types", "TriggerScheduleQuery": ".types", "TriggerScheduleResponse": ".types", "TriggerSchedulesResponse": ".types", "TriggerSubscription": ".types", "TriggerSubscriptionCreate": ".types", "TriggerSubscriptionCreateRequest": ".types", "TriggerSubscriptionData": ".types", "TriggerSubscriptionDataInputsFields": ".types", "TriggerSubscriptionEdit": ".types", "TriggerSubscriptionFlags": ".types", "TriggerSubscriptionQuery": ".types", "TriggerSubscriptionResponse": ".types", "TriggerSubscriptionsResponse": ".types", "UnprocessableEntityError": ".errors", "UserIdsResponse": ".types", "ValidationError": ".types", "ValidationErrorLocItem": ".types", "WebhookDeliveriesResponse": ".types", "WebhookDelivery": ".types", "WebhookDeliveryCreate": ".types", "WebhookDeliveryData": ".types", "WebhookDeliveryQuery": ".types", "WebhookDeliveryResponse": ".types", "WebhookDeliveryResponseInfo": ".types", "WebhookEventType": ".types", "WebhookProviderDto": ".types", "WebhookProviderSettingsDto": ".types", "WebhookSubscription": ".types", "WebhookSubscriptionCreate": ".types", "WebhookSubscriptionData": ".types", "WebhookSubscriptionDataAuthMode": ".types", "WebhookSubscriptionEdit": ".types", "WebhookSubscriptionFlags": ".types", "WebhookSubscriptionQuery": ".types", "WebhookSubscriptionResponse": ".types", "WebhookSubscriptionTestRequestSubscription": ".webhooks", "WebhookSubscriptionsResponse": ".types", "Windowing": ".types", "WindowingOrder": ".types", "Workflow": ".types", "WorkflowArtifactFlags": ".types", "WorkflowCatalogFlags": ".types", "WorkflowCatalogHarness": ".types", "WorkflowCatalogHarnessResponse": ".types", "WorkflowCatalogHarnessesResponse": ".types", "WorkflowCatalogPreset": ".types", "WorkflowCatalogPresetResponse": ".types", "WorkflowCatalogPresetsResponse": ".types", "WorkflowCatalogTemplate": ".types", "WorkflowCatalogTemplateResponse": ".types", "WorkflowCatalogTemplatesResponse": ".types", "WorkflowCatalogType": ".types", "WorkflowCatalogTypeResponse": ".types", "WorkflowCatalogTypesResponse": ".types", "WorkflowCreate": ".types", "WorkflowEdit": ".types", "WorkflowFlags": ".types", "WorkflowRequestData": ".types", "WorkflowResponse": ".types", "WorkflowRevisionCommit": ".types", "WorkflowRevisionCreate": ".types", "WorkflowRevisionDataInput": ".types", "WorkflowRevisionDataInputHeadersValue": ".types", "WorkflowRevisionDataInputRuntime": ".types", "WorkflowRevisionDataOutput": ".types", "WorkflowRevisionDataOutputHeadersValue": ".types", "WorkflowRevisionDataOutputRuntime": ".types", "WorkflowRevisionDelta": ".types", "WorkflowRevisionEdit": ".types", "WorkflowRevisionFlags": ".types", "WorkflowRevisionInput": ".types", "WorkflowRevisionOutput": ".types", "WorkflowRevisionResolveResponse": ".types", "WorkflowRevisionResponse": ".types", "WorkflowRevisionsLog": ".types", "WorkflowRevisionsResponse": ".types", "WorkflowVariant": ".types", "WorkflowVariantCreate": ".types", "WorkflowVariantEdit": ".types", "WorkflowVariantFlags": ".types", "WorkflowVariantFork": ".types", "WorkflowVariantResponse": ".types", "WorkflowVariantsResponse": ".types", "WorkflowsResponse": ".types", "Workspace": ".types", "WorkspaceMemberResponse": ".types", "WorkspacePermission": ".types", "WorkspaceResponse": ".types", "access": ".access", "annotations": ".annotations", "applications": ".applications", "billing": ".billing", "environments": ".environments", "evaluations": ".evaluations", "evaluators": ".evaluators", "events": ".events", "folders": ".folders", "invocations": ".invocations", "keys": ".keys", "legacy": ".legacy", "mounts": ".mounts", "organizations": ".organizations", "projects": ".projects", "queries": ".queries", "secrets": ".secrets", "sessions": ".sessions", "status": ".status", "testcases": ".testcases", "testsets": ".testsets", "tools": ".tools", "traces": ".traces", "triggers": ".triggers", "users": ".users", "webhooks": ".webhooks", "workflows": ".workflows", "workspaces": ".workspaces"} def __getattr__(attr_name: str) -> typing.Any: module_name = _dynamic_imports.get(attr_name) if module_name is None: @@ -38,4 +38,4 @@ def __getattr__(attr_name: str) -> typing.Any: def __dir__(): lazy_attrs = list(_dynamic_imports.keys()) return sorted(lazy_attrs) -__all__ = ["AdminAccountCreateOptions", "AdminAccountRead", "AdminAccountsCreate", "AdminAccountsDelete", "AdminAccountsDeleteTarget", "AdminAccountsResponse", "AdminApiKeyCreate", "AdminApiKeyResponse", "AdminDeleteResponse", "AdminDeletedEntities", "AdminDeletedEntity", "AdminOrganizationCreate", "AdminOrganizationMembershipCreate", "AdminOrganizationMembershipRead", "AdminOrganizationRead", "AdminProjectCreate", "AdminProjectMembershipCreate", "AdminProjectMembershipRead", "AdminProjectRead", "AdminSimpleAccountCreate", "AdminSimpleAccountDeleteEntry", "AdminSimpleAccountRead", "AdminSimpleAccountsApiKeysCreate", "AdminSimpleAccountsCreate", "AdminSimpleAccountsDelete", "AdminSimpleAccountsOrganizationsCreate", "AdminSimpleAccountsOrganizationsMembershipsCreate", "AdminSimpleAccountsOrganizationsTransferOwnership", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjects", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjectsZero", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspaces", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspacesZero", "AdminSimpleAccountsOrganizationsTransferOwnershipResponse", "AdminSimpleAccountsProjectsCreate", "AdminSimpleAccountsProjectsMembershipsCreate", "AdminSimpleAccountsResponse", "AdminSimpleAccountsUsersCreate", "AdminSimpleAccountsUsersIdentitiesCreate", "AdminSimpleAccountsUsersResetPassword", "AdminSimpleAccountsWorkspacesCreate", "AdminSimpleAccountsWorkspacesMembershipsCreate", "AdminStructuredError", "AdminSubscriptionCreate", "AdminSubscriptionRead", "AdminUserCreate", "AdminUserIdentityCreate", "AdminUserIdentityRead", "AdminUserIdentityReadStatus", "AdminUserRead", "AdminWorkspaceCreate", "AdminWorkspaceMembershipCreate", "AdminWorkspaceMembershipRead", "AdminWorkspaceRead", "AgentTemplateOverlay", "AgentaApi", "AgentaApiEnvironment", "Analytics", "AnalyticsResponse", "Annotation", "AnnotationCreate", "AnnotationCreateLinks", "AnnotationEdit", "AnnotationEditLinks", "AnnotationLinkResponse", "AnnotationLinks", "AnnotationQuery", "AnnotationQueryLinks", "AnnotationResponse", "AnnotationsResponse", "Application", "ApplicationArtifactFlags", "ApplicationArtifactQueryFlags", "ApplicationCatalogPreset", "ApplicationCatalogPresetResponse", "ApplicationCatalogPresetsResponse", "ApplicationCatalogTemplate", "ApplicationCatalogTemplateResponse", "ApplicationCatalogTemplatesResponse", "ApplicationCatalogType", "ApplicationCatalogTypesResponse", "ApplicationCreate", "ApplicationEdit", "ApplicationFlags", "ApplicationQuery", "ApplicationResponse", "ApplicationRevisionCommit", "ApplicationRevisionCreate", "ApplicationRevisionDataInput", "ApplicationRevisionDataInputHeadersValue", "ApplicationRevisionDataInputRuntime", "ApplicationRevisionDataOutput", "ApplicationRevisionDataOutputHeadersValue", "ApplicationRevisionDataOutputRuntime", "ApplicationRevisionEdit", "ApplicationRevisionFlags", "ApplicationRevisionInput", "ApplicationRevisionOutput", "ApplicationRevisionQuery", "ApplicationRevisionQueryFlags", "ApplicationRevisionResolveResponse", "ApplicationRevisionResponse", "ApplicationRevisionsLog", "ApplicationRevisionsResponse", "ApplicationVariant", "ApplicationVariantCreate", "ApplicationVariantEdit", "ApplicationVariantFlags", "ApplicationVariantFork", "ApplicationVariantResponse", "ApplicationVariantsResponse", "ApplicationsResponse", "AsyncAgentaApi", "BodyConfigsFetchVariantsConfigsFetchPost", "Bucket", "BuiltinToolConfig", "BuiltinToolConfigPermission", "CapabilitiesResult", "Capability", "CapabilityConnection", "CapabilityGuidance", "CollectStatusResponse", "CommandMode", "ComparisonOperator", "Condition", "ConditionOperator", "ConditionOptions", "ConditionValue", "ConfigResponseModel", "ConnectAffordance", "ConnectionRequirement", "CreateSimpleTestsetFromFileRequestFileType", "CreateTestsetRevisionFromFileRequestFileType", "CustomModelSettingsDto", "CustomProviderDto", "CustomProviderKind", "CustomProviderSettingsDto", "CustomSecretDto", "CustomSecretFormat", "CustomSecretSettingsDto", "CustomSecretSettingsDtoContent", "CustomSecretSettingsDtoContentOneValue", "DictOperator", "DiscoverResponse", "DiscoverResponseMethodsValue", "DiscoveredAlternative", "DiscoveredTool", "DiscoveredToolType", "DiscoveredTriggerAlternative", "DiscoveredTriggerEvent", "DiscoveredTriggerEventType", "EditSimpleTestsetFromFileRequestFileType", "EntityRef", "Environment", "EnvironmentCreate", "EnvironmentEdit", "EnvironmentFlags", "EnvironmentQueryFlags", "EnvironmentResponse", "EnvironmentRevisionCommit", "EnvironmentRevisionCreate", "EnvironmentRevisionData", "EnvironmentRevisionDelta", "EnvironmentRevisionEdit", "EnvironmentRevisionInput", "EnvironmentRevisionOutput", "EnvironmentRevisionResolveResponse", "EnvironmentRevisionResponse", "EnvironmentRevisionsLog", "EnvironmentRevisionsResponse", "EnvironmentVariant", "EnvironmentVariantCreate", "EnvironmentVariantEdit", "EnvironmentVariantFork", "EnvironmentVariantResponse", "EnvironmentVariantsResponse", "EnvironmentsResponse", "ErrorPolicy", "EvaluationMetrics", "EvaluationMetricsCreate", "EvaluationMetricsIdsResponse", "EvaluationMetricsQuery", "EvaluationMetricsQueryScenarioIds", "EvaluationMetricsQueryTimestamps", "EvaluationMetricsRefresh", "EvaluationMetricsResponse", "EvaluationMetricsSetRequest", "EvaluationQueue", "EvaluationQueueCreate", "EvaluationQueueData", "EvaluationQueueEdit", "EvaluationQueueFlags", "EvaluationQueueIdResponse", "EvaluationQueueIdsResponse", "EvaluationQueueQuery", "EvaluationQueueQueryFlags", "EvaluationQueueResponse", "EvaluationQueueScenariosQuery", "EvaluationQueuesResponse", "EvaluationResult", "EvaluationResultCreate", "EvaluationResultIdResponse", "EvaluationResultIdsResponse", "EvaluationResultQuery", "EvaluationResultResponse", "EvaluationResultsResponse", "EvaluationResultsSetRequest", "EvaluationRun", "EvaluationRunCreate", "EvaluationRunDataConcurrency", "EvaluationRunDataInput", "EvaluationRunDataMapping", "EvaluationRunDataMappingColumn", "EvaluationRunDataMappingStep", "EvaluationRunDataOutput", "EvaluationRunDataStepInput", "EvaluationRunDataStepInputKey", "EvaluationRunDataStepInputOrigin", "EvaluationRunDataStepInputType", "EvaluationRunDataStepOutput", "EvaluationRunDataStepOutputOrigin", "EvaluationRunDataStepOutputType", "EvaluationRunEdit", "EvaluationRunFlags", "EvaluationRunIdResponse", "EvaluationRunIdsRequest", "EvaluationRunIdsResponse", "EvaluationRunQuery", "EvaluationRunQueryFlags", "EvaluationRunResponse", "EvaluationRunsResponse", "EvaluationScenario", "EvaluationScenarioCreate", "EvaluationScenarioEdit", "EvaluationScenarioIdResponse", "EvaluationScenarioIdsResponse", "EvaluationScenarioQuery", "EvaluationScenarioResponse", "EvaluationScenariosResponse", "EvaluationStatus", "Evaluator", "EvaluatorArtifactFlags", "EvaluatorArtifactQueryFlags", "EvaluatorCatalogPreset", "EvaluatorCatalogPresetResponse", "EvaluatorCatalogPresetsResponse", "EvaluatorCatalogTemplate", "EvaluatorCatalogTemplateResponse", "EvaluatorCatalogTemplatesResponse", "EvaluatorCatalogType", "EvaluatorCatalogTypesResponse", "EvaluatorCreate", "EvaluatorEdit", "EvaluatorFlags", "EvaluatorQuery", "EvaluatorResponse", "EvaluatorRevisionCommit", "EvaluatorRevisionCreate", "EvaluatorRevisionDataInput", "EvaluatorRevisionDataInputHeadersValue", "EvaluatorRevisionDataInputRuntime", "EvaluatorRevisionDataOutput", "EvaluatorRevisionDataOutputHeadersValue", "EvaluatorRevisionDataOutputRuntime", "EvaluatorRevisionEdit", "EvaluatorRevisionFlags", "EvaluatorRevisionInput", "EvaluatorRevisionOutput", "EvaluatorRevisionQuery", "EvaluatorRevisionQueryFlags", "EvaluatorRevisionResolveResponse", "EvaluatorRevisionResponse", "EvaluatorRevisionsLog", "EvaluatorRevisionsResponse", "EvaluatorTemplate", "EvaluatorTemplatesResponse", "EvaluatorVariant", "EvaluatorVariantCreate", "EvaluatorVariantEdit", "EvaluatorVariantFlags", "EvaluatorVariantFork", "EvaluatorVariantResponse", "EvaluatorVariantsResponse", "EvaluatorsResponse", "Event", "EventQuery", "EventType", "EventsQueryResponse", "ExistenceOperator", "FetchLegacyAnalyticsRequestNewest", "FetchLegacyAnalyticsRequestOldest", "FetchSimpleTestsetToFileRequestFileType", "FetchTestsetRevisionToFileRequestFileType", "FilteringInput", "FilteringInputConditionsItem", "FilteringOutput", "FilteringOutputConditionsItem", "Focus", "Folder", "FolderCreate", "FolderEdit", "FolderIdResponse", "FolderKind", "FolderQuery", "FolderQueryKinds", "FolderResponse", "FoldersResponse", "Format", "Formatting", typing.Any, typing.Any, "GatewayToolConfig", "GatewayToolConfigPermission", "HarnessSessionRecord", "Header", "HttpValidationError", "InviteRequest", "Invocation", "InvocationCreate", "InvocationCreateLinks", "InvocationEdit", "InvocationEditLinks", "InvocationLinkResponse", "InvocationLinks", "InvocationQuery", "InvocationQueryLinks", "InvocationResponse", "InvocationsResponse", "JsonSchemasInput", "JsonSchemasOutput", typing.Any, typing.Any, "LegacyLifecycleDto", "ListApiKeysResponse", "ListOperator", "ListOptions", "LogicalOperator", "MetricSpec", "MetricType", "MetricsBucket", "Mount", "MountCreate", "MountCredentials", "MountCredentialsResponse", "MountData", "MountEdit", "MountFileDeletedResponse", "MountFileWrittenResponse", "MountFlags", "MountFolderCreatedResponse", "MountQuery", "MountResponse", "MountsResponse", "NumericOperator", "OTelEventInput", "OTelEventInputTimestamp", "OTelEventOutput", "OTelEventOutputTimestamp", "OTelHashInput", "OTelHashOutput", "OTelLinkInput", "OTelLinkOutput", "OTelLinksResponse", "OTelReferenceInput", "OTelReferenceOutput", "OTelSpanKind", "OTelStatusCode", "OTelTracingRequest", "OTelTracingResponse", "OldAnalyticsResponse", "Organization", "OrganizationDetails", "OrganizationDomainResponse", "OrganizationProviderResponse", "OrganizationUpdate", "Permission", "PlaygroundBuildKitContext", "ProjectsResponse", "QueriesResponse", "Query", "QueryApplicationVariantsRequestOrder", "QueryCreate", "QueryEdit", "QueryEnvironmentRevisionsRequestOrder", "QueryEnvironmentVariantsRequestOrder", "QueryEnvironmentsRequestOrder", "QueryEvaluatorVariantsRequestOrder", "QueryFlags", "QueryQueriesRequestOrder", "QueryQueryFlags", "QueryResponse", "QueryRevision", "QueryRevisionCommit", "QueryRevisionCreate", "QueryRevisionDataInput", "QueryRevisionDataOutput", "QueryRevisionEdit", "QueryRevisionQuery", "QueryRevisionResponse", "QueryRevisionsLog", "QueryRevisionsResponse", "QuerySpansAnalyticsRequestNewest", "QuerySpansAnalyticsRequestOldest", "QueryVariant", "QueryVariantCreate", "QueryVariantEdit", "QueryVariantFork", "QueryVariantQuery", "QueryVariantResponse", "QueryVariantsResponse", "QueryWorkflowRevisionsRequestOrder", "QueryWorkflowVariantsRequestOrder", "QueryWorkflowsRequestOrder", "Reference", "ReferenceRequestModelInput", "ReferenceRequestModelOutput", "RequestType", "ResolutionInfo", "ResolvedTool", "RetrievalInfo", "SecretDto", "SecretDtoData", "SecretKind", "SecretResponseDto", "SecretResponseDtoData", "Selector", "SessionHeartbeatResponseModel", "SessionIdsResponse", "SessionInteraction", "SessionInteractionData", "SessionInteractionFlags", "SessionInteractionKind", "SessionInteractionQuery", "SessionInteractionQueryFlags", "SessionInteractionResponse", "SessionInteractionStatus", "SessionInteractionsResponse", "SessionMount", "SessionMountQuery", "SessionMountsResponse", "SessionRecord", "SessionRecordResponse", "SessionRecordsQueryResponse", "SessionState", "SessionStateData", "SessionStateFlags", "SessionStateResponse", "SessionStateUpsertRequest", "SessionStream", "SessionStreamCommandResponseModel", "SessionStreamFlags", "SessionStreamResponseModel", "SessionStreamsResponseModel", "SimpleApplication", "SimpleApplicationAdditionalContext", "SimpleApplicationCreate", "SimpleApplicationDataInput", "SimpleApplicationDataInputHeadersValue", "SimpleApplicationDataInputRuntime", "SimpleApplicationDataOutput", "SimpleApplicationDataOutputHeadersValue", "SimpleApplicationDataOutputRuntime", "SimpleApplicationEdit", "SimpleApplicationFlags", "SimpleApplicationQuery", "SimpleApplicationQueryFlags", "SimpleApplicationResponse", "SimpleApplicationsResponse", "SimpleEnvironment", "SimpleEnvironmentCreate", "SimpleEnvironmentEdit", "SimpleEnvironmentQuery", "SimpleEnvironmentResponse", "SimpleEnvironmentsResponse", "SimpleEvaluation", "SimpleEvaluationCreate", "SimpleEvaluationData", "SimpleEvaluationDataApplicationSteps", "SimpleEvaluationDataApplicationStepsOneValue", "SimpleEvaluationDataEvaluatorSteps", "SimpleEvaluationDataEvaluatorStepsOneValue", "SimpleEvaluationDataQuerySteps", "SimpleEvaluationDataQueryStepsOneValue", "SimpleEvaluationDataTestsetSteps", "SimpleEvaluationDataTestsetStepsOneValue", "SimpleEvaluationEdit", "SimpleEvaluationIdResponse", "SimpleEvaluationQuery", "SimpleEvaluationResponse", "SimpleEvaluationsResponse", "SimpleEvaluator", "SimpleEvaluatorCreate", "SimpleEvaluatorDataInput", "SimpleEvaluatorDataInputHeadersValue", "SimpleEvaluatorDataInputRuntime", "SimpleEvaluatorDataOutput", "SimpleEvaluatorDataOutputHeadersValue", "SimpleEvaluatorDataOutputRuntime", "SimpleEvaluatorEdit", "SimpleEvaluatorFlags", "SimpleEvaluatorQuery", "SimpleEvaluatorQueryFlags", "SimpleEvaluatorResponse", "SimpleEvaluatorsResponse", "SimpleQueriesResponse", "SimpleQuery", "SimpleQueryCreate", "SimpleQueryEdit", "SimpleQueryQuery", "SimpleQueryResponse", "SimpleQueue", "SimpleQueueCreate", "SimpleQueueData", "SimpleQueueDataEvaluators", "SimpleQueueDataEvaluatorsOneValue", "SimpleQueueIdResponse", "SimpleQueueIdsResponse", "SimpleQueueKind", "SimpleQueueQuery", "SimpleQueueResponse", "SimpleQueueScenariosQuery", "SimpleQueueScenariosResponse", "SimpleQueueSettings", "SimpleQueuesResponse", "SimpleTestset", "SimpleTestsetCreate", "SimpleTestsetEdit", "SimpleTestsetQuery", "SimpleTestsetResponse", "SimpleTestsetsResponse", "SimpleTrace", "SimpleTraceChannel", "SimpleTraceCreate", "SimpleTraceCreateLinks", "SimpleTraceEdit", "SimpleTraceEditLinks", "SimpleTraceKind", "SimpleTraceLinkResponse", "SimpleTraceLinks", "SimpleTraceOrigin", "SimpleTraceQuery", "SimpleTraceQueryLinks", "SimpleTraceReferences", "SimpleTraceResponse", "SimpleTracesResponse", "SimpleWorkflow", "SimpleWorkflowCreate", "SimpleWorkflowDataInput", "SimpleWorkflowDataInputHeadersValue", "SimpleWorkflowDataInputRuntime", "SimpleWorkflowDataOutput", "SimpleWorkflowDataOutputHeadersValue", "SimpleWorkflowDataOutputRuntime", "SimpleWorkflowEdit", "SimpleWorkflowFlags", "SimpleWorkflowQuery", "SimpleWorkflowQueryFlags", "SimpleWorkflowResponse", "SimpleWorkflowsResponse", "SpanInput", "SpanInputEndTime", "SpanInputStartTime", "SpanOutput", "SpanOutputEndTime", "SpanOutputStartTime", "SpanResponse", "SpanType", "SpansNodeInput", "SpansNodeInputEndTime", "SpansNodeInputSpansValue", "SpansNodeInputStartTime", "SpansNodeOutput", "SpansNodeOutputEndTime", "SpansNodeOutputSpansValue", "SpansNodeOutputStartTime", "SpansResponse", "SpansTreeInput", "SpansTreeInputSpansValue", "SpansTreeOutput", "SpansTreeOutputSpansValue", "SsoProviderDto", "SsoProviderInfo", "SsoProviderSettingsDto", "SsoProviders", "StandardProviderDto", "StandardProviderKind", "StandardProviderSettingsDto", "Status", "StringOperator", "TestcaseInput", "TestcaseOutput", "TestcaseResponse", "TestcasesResponse", "Testset", "TestsetCreate", "TestsetEdit", "TestsetFlags", "TestsetQuery", "TestsetResponse", "TestsetRevision", "TestsetRevisionCommit", "TestsetRevisionCreate", "TestsetRevisionDataInput", "TestsetRevisionDataOutput", "TestsetRevisionDelta", "TestsetRevisionDeltaColumns", "TestsetRevisionDeltaRows", "TestsetRevisionEdit", "TestsetRevisionQuery", "TestsetRevisionResponse", "TestsetRevisionsLog", "TestsetRevisionsResponse", "TestsetVariant", "TestsetVariantCreate", "TestsetVariantEdit", "TestsetVariantFork", "TestsetVariantQuery", "TestsetVariantResponse", "TestsetVariantsResponse", "TestsetsResponse", "TextOptions", "ToolAuthScheme", "ToolCallData", "ToolCallFunction", "ToolCallResponse", "ToolCatalogAction", "ToolCatalogActionDetails", "ToolCatalogActionResponse", "ToolCatalogActionResponseAction", "ToolCatalogActionsResponse", "ToolCatalogActionsResponseActionsItem", "ToolCatalogCategoriesResponse", "ToolCatalogCategory", "ToolCatalogIntegration", "ToolCatalogIntegrationDetails", "ToolCatalogIntegrationResponse", "ToolCatalogIntegrationResponseIntegration", "ToolCatalogIntegrationsResponse", "ToolCatalogIntegrationsResponseIntegrationsItem", "ToolCatalogProvider", "ToolCatalogProviderDetails", "ToolCatalogProviderResponse", "ToolCatalogProviderResponseProvider", "ToolCatalogProvidersResponse", "ToolCatalogProvidersResponseProvidersItem", "ToolConnection", "ToolConnectionCreate", "ToolConnectionCreateData", "ToolConnectionResponse", "ToolConnectionState", "ToolConnectionStatus", "ToolConnectionsResponse", "ToolProviderKind", "ToolResolveRequestToolsItem", "ToolResolveRequestToolsItem_Builtin", "ToolResolveRequestToolsItem_Gateway", "ToolResolveResponse", "ToolResult", "ToolResultData", "TraceIdResponse", "TraceIdsResponse", "TraceInput", "TraceInputSpansValue", "TraceOutput", "TraceOutputSpansValue", "TraceRequest", "TraceResponse", "TraceType", "TracesRequest", "TracesResponse", "TracingQuery", "TriggerAuthScheme", "TriggerCapabilitiesResult", "TriggerCapability", "TriggerCapabilityConnection", "TriggerCatalogEvent", "TriggerCatalogEventDetails", "TriggerCatalogEventResponse", "TriggerCatalogEventsResponse", "TriggerCatalogIntegration", "TriggerCatalogIntegrationResponse", "TriggerCatalogIntegrationsResponse", "TriggerCatalogProvider", "TriggerCatalogProviderResponse", "TriggerCatalogProvidersResponse", "TriggerConnectAffordance", "TriggerConnection", "TriggerConnectionCreate", "TriggerConnectionCreateData", "TriggerConnectionRequirement", "TriggerConnectionResponse", "TriggerConnectionStatus", "TriggerConnectionsResponse", "TriggerDeliveriesResponse", "TriggerDelivery", "TriggerDeliveryData", "TriggerDeliveryQuery", "TriggerDeliveryResponse", "TriggerDiscoveryConnectionState", "TriggerDiscoveryGuidance", "TriggerEventAck", "TriggerProviderKind", "TriggerSchedule", "TriggerScheduleCreate", "TriggerScheduleData", "TriggerScheduleDataInputsFields", "TriggerScheduleEdit", "TriggerScheduleFlags", "TriggerScheduleQuery", "TriggerScheduleResponse", "TriggerSchedulesResponse", "TriggerSubscription", "TriggerSubscriptionCreate", "TriggerSubscriptionCreateRequest", "TriggerSubscriptionData", "TriggerSubscriptionDataInputsFields", "TriggerSubscriptionEdit", "TriggerSubscriptionFlags", "TriggerSubscriptionQuery", "TriggerSubscriptionResponse", "TriggerSubscriptionsResponse", "UnprocessableEntityError", "UserIdsResponse", "ValidationError", "ValidationErrorLocItem", "WebhookDeliveriesResponse", "WebhookDelivery", "WebhookDeliveryCreate", "WebhookDeliveryData", "WebhookDeliveryQuery", "WebhookDeliveryResponse", "WebhookDeliveryResponseInfo", "WebhookEventType", "WebhookProviderDto", "WebhookProviderSettingsDto", "WebhookSubscription", "WebhookSubscriptionCreate", "WebhookSubscriptionData", "WebhookSubscriptionDataAuthMode", "WebhookSubscriptionEdit", "WebhookSubscriptionFlags", "WebhookSubscriptionQuery", "WebhookSubscriptionResponse", "WebhookSubscriptionTestRequestSubscription", "WebhookSubscriptionsResponse", "Windowing", "WindowingOrder", "Workflow", "WorkflowArtifactFlags", "WorkflowCatalogFlags", "WorkflowCatalogHarness", "WorkflowCatalogHarnessResponse", "WorkflowCatalogHarnessesResponse", "WorkflowCatalogPreset", "WorkflowCatalogPresetResponse", "WorkflowCatalogPresetsResponse", "WorkflowCatalogTemplate", "WorkflowCatalogTemplateResponse", "WorkflowCatalogTemplatesResponse", "WorkflowCatalogType", "WorkflowCatalogTypeResponse", "WorkflowCatalogTypesResponse", "WorkflowCreate", "WorkflowEdit", "WorkflowFlags", "WorkflowResponse", "WorkflowRevisionCommit", "WorkflowRevisionCreate", "WorkflowRevisionDataInput", "WorkflowRevisionDataInputHeadersValue", "WorkflowRevisionDataInputRuntime", "WorkflowRevisionDataOutput", "WorkflowRevisionDataOutputHeadersValue", "WorkflowRevisionDataOutputRuntime", "WorkflowRevisionDelta", "WorkflowRevisionEdit", "WorkflowRevisionFlags", "WorkflowRevisionInput", "WorkflowRevisionOutput", "WorkflowRevisionResolveResponse", "WorkflowRevisionResponse", "WorkflowRevisionsLog", "WorkflowRevisionsResponse", "WorkflowVariant", "WorkflowVariantCreate", "WorkflowVariantEdit", "WorkflowVariantFlags", "WorkflowVariantFork", "WorkflowVariantResponse", "WorkflowVariantsResponse", "WorkflowsResponse", "Workspace", "WorkspaceMemberResponse", "WorkspacePermission", "WorkspaceResponse", "access", "annotations", "applications", "billing", "environments", "evaluations", "evaluators", "events", "folders", "invocations", "keys", "legacy", "mounts", "organizations", "projects", "queries", "secrets", "sessions", "status", "testcases", "testsets", "tools", "traces", "triggers", "users", "webhooks", "workflows", "workspaces"] +__all__ = ["AdminAccountCreateOptions", "AdminAccountRead", "AdminAccountsCreate", "AdminAccountsDelete", "AdminAccountsDeleteTarget", "AdminAccountsResponse", "AdminApiKeyCreate", "AdminApiKeyResponse", "AdminDeleteResponse", "AdminDeletedEntities", "AdminDeletedEntity", "AdminOrganizationCreate", "AdminOrganizationMembershipCreate", "AdminOrganizationMembershipRead", "AdminOrganizationRead", "AdminProjectCreate", "AdminProjectMembershipCreate", "AdminProjectMembershipRead", "AdminProjectRead", "AdminSimpleAccountCreate", "AdminSimpleAccountDeleteEntry", "AdminSimpleAccountRead", "AdminSimpleAccountsApiKeysCreate", "AdminSimpleAccountsCreate", "AdminSimpleAccountsDelete", "AdminSimpleAccountsOrganizationsCreate", "AdminSimpleAccountsOrganizationsMembershipsCreate", "AdminSimpleAccountsOrganizationsTransferOwnership", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjects", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjectsZero", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspaces", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspacesZero", "AdminSimpleAccountsOrganizationsTransferOwnershipResponse", "AdminSimpleAccountsProjectsCreate", "AdminSimpleAccountsProjectsMembershipsCreate", "AdminSimpleAccountsResponse", "AdminSimpleAccountsUsersCreate", "AdminSimpleAccountsUsersIdentitiesCreate", "AdminSimpleAccountsUsersResetPassword", "AdminSimpleAccountsWorkspacesCreate", "AdminSimpleAccountsWorkspacesMembershipsCreate", "AdminStructuredError", "AdminSubscriptionCreate", "AdminSubscriptionRead", "AdminUserCreate", "AdminUserIdentityCreate", "AdminUserIdentityRead", "AdminUserIdentityReadStatus", "AdminUserRead", "AdminWorkspaceCreate", "AdminWorkspaceMembershipCreate", "AdminWorkspaceMembershipRead", "AdminWorkspaceRead", "AgentTemplateOverlay", "AgentaApi", "AgentaApiEnvironment", "Analytics", "AnalyticsResponse", "Annotation", "AnnotationCreate", "AnnotationCreateLinks", "AnnotationEdit", "AnnotationEditLinks", "AnnotationLinkResponse", "AnnotationLinks", "AnnotationQuery", "AnnotationQueryLinks", "AnnotationResponse", "AnnotationsResponse", "Application", "ApplicationArtifactFlags", "ApplicationArtifactQueryFlags", "ApplicationCatalogPreset", "ApplicationCatalogPresetResponse", "ApplicationCatalogPresetsResponse", "ApplicationCatalogTemplate", "ApplicationCatalogTemplateResponse", "ApplicationCatalogTemplatesResponse", "ApplicationCatalogType", "ApplicationCatalogTypesResponse", "ApplicationCreate", "ApplicationEdit", "ApplicationFlags", "ApplicationQuery", "ApplicationResponse", "ApplicationRevisionCommit", "ApplicationRevisionCreate", "ApplicationRevisionDataInput", "ApplicationRevisionDataInputHeadersValue", "ApplicationRevisionDataInputRuntime", "ApplicationRevisionDataOutput", "ApplicationRevisionDataOutputHeadersValue", "ApplicationRevisionDataOutputRuntime", "ApplicationRevisionEdit", "ApplicationRevisionFlags", "ApplicationRevisionInput", "ApplicationRevisionOutput", "ApplicationRevisionQuery", "ApplicationRevisionQueryFlags", "ApplicationRevisionResolveResponse", "ApplicationRevisionResponse", "ApplicationRevisionsLog", "ApplicationRevisionsResponse", "ApplicationVariant", "ApplicationVariantCreate", "ApplicationVariantEdit", "ApplicationVariantFlags", "ApplicationVariantFork", "ApplicationVariantResponse", "ApplicationVariantsResponse", "ApplicationsResponse", "AsyncAgentaApi", "BodyConfigsFetchVariantsConfigsFetchPost", "Bucket", "BuiltinToolConfig", "BuiltinToolConfigPermission", "CapabilitiesResult", "Capability", "CapabilityConnection", "CapabilityGuidance", "CollectStatusResponse", "CommandMode", "ComparisonOperator", "Condition", "ConditionOperator", "ConditionOptions", "ConditionValue", "ConfigResponseModel", "ConnectAffordance", "ConnectionRequirement", "CreateSimpleTestsetFromFileRequestFileType", "CreateTestsetRevisionFromFileRequestFileType", "CustomModelSettingsDto", "CustomProviderDto", "CustomProviderKind", "CustomProviderSettingsDto", "CustomSecretDto", "CustomSecretFormat", "CustomSecretSettingsDto", "CustomSecretSettingsDtoContent", "CustomSecretSettingsDtoContentOneValue", "DictOperator", "DiscoverResponse", "DiscoverResponseMethodsValue", "DiscoveredAlternative", "DiscoveredTool", "DiscoveredToolType", "DiscoveredTriggerAlternative", "DiscoveredTriggerEvent", "DiscoveredTriggerEventType", "EditSimpleTestsetFromFileRequestFileType", "EntityRef", "Environment", "EnvironmentCreate", "EnvironmentEdit", "EnvironmentFlags", "EnvironmentQueryFlags", "EnvironmentResponse", "EnvironmentRevisionCommit", "EnvironmentRevisionCreate", "EnvironmentRevisionData", "EnvironmentRevisionDelta", "EnvironmentRevisionEdit", "EnvironmentRevisionInput", "EnvironmentRevisionOutput", "EnvironmentRevisionResolveResponse", "EnvironmentRevisionResponse", "EnvironmentRevisionsLog", "EnvironmentRevisionsResponse", "EnvironmentVariant", "EnvironmentVariantCreate", "EnvironmentVariantEdit", "EnvironmentVariantFork", "EnvironmentVariantResponse", "EnvironmentVariantsResponse", "EnvironmentsResponse", "ErrorPolicy", "EvaluationMetrics", "EvaluationMetricsCreate", "EvaluationMetricsIdsResponse", "EvaluationMetricsQuery", "EvaluationMetricsQueryScenarioIds", "EvaluationMetricsQueryTimestamps", "EvaluationMetricsRefresh", "EvaluationMetricsResponse", "EvaluationMetricsSetRequest", "EvaluationQueue", "EvaluationQueueCreate", "EvaluationQueueData", "EvaluationQueueEdit", "EvaluationQueueFlags", "EvaluationQueueIdResponse", "EvaluationQueueIdsResponse", "EvaluationQueueQuery", "EvaluationQueueQueryFlags", "EvaluationQueueResponse", "EvaluationQueueScenariosQuery", "EvaluationQueuesResponse", "EvaluationResult", "EvaluationResultCreate", "EvaluationResultIdResponse", "EvaluationResultIdsResponse", "EvaluationResultQuery", "EvaluationResultResponse", "EvaluationResultsResponse", "EvaluationResultsSetRequest", "EvaluationRun", "EvaluationRunCreate", "EvaluationRunDataConcurrency", "EvaluationRunDataInput", "EvaluationRunDataMapping", "EvaluationRunDataMappingColumn", "EvaluationRunDataMappingStep", "EvaluationRunDataOutput", "EvaluationRunDataStepInput", "EvaluationRunDataStepInputKey", "EvaluationRunDataStepInputOrigin", "EvaluationRunDataStepInputType", "EvaluationRunDataStepOutput", "EvaluationRunDataStepOutputOrigin", "EvaluationRunDataStepOutputType", "EvaluationRunEdit", "EvaluationRunFlags", "EvaluationRunIdResponse", "EvaluationRunIdsRequest", "EvaluationRunIdsResponse", "EvaluationRunQuery", "EvaluationRunQueryFlags", "EvaluationRunResponse", "EvaluationRunsResponse", "EvaluationScenario", "EvaluationScenarioCreate", "EvaluationScenarioEdit", "EvaluationScenarioIdResponse", "EvaluationScenarioIdsResponse", "EvaluationScenarioQuery", "EvaluationScenarioResponse", "EvaluationScenariosResponse", "EvaluationStatus", "Evaluator", "EvaluatorArtifactFlags", "EvaluatorArtifactQueryFlags", "EvaluatorCatalogPreset", "EvaluatorCatalogPresetResponse", "EvaluatorCatalogPresetsResponse", "EvaluatorCatalogTemplate", "EvaluatorCatalogTemplateResponse", "EvaluatorCatalogTemplatesResponse", "EvaluatorCatalogType", "EvaluatorCatalogTypesResponse", "EvaluatorCreate", "EvaluatorEdit", "EvaluatorFlags", "EvaluatorQuery", "EvaluatorResponse", "EvaluatorRevisionCommit", "EvaluatorRevisionCreate", "EvaluatorRevisionDataInput", "EvaluatorRevisionDataInputHeadersValue", "EvaluatorRevisionDataInputRuntime", "EvaluatorRevisionDataOutput", "EvaluatorRevisionDataOutputHeadersValue", "EvaluatorRevisionDataOutputRuntime", "EvaluatorRevisionEdit", "EvaluatorRevisionFlags", "EvaluatorRevisionInput", "EvaluatorRevisionOutput", "EvaluatorRevisionQuery", "EvaluatorRevisionQueryFlags", "EvaluatorRevisionResolveResponse", "EvaluatorRevisionResponse", "EvaluatorRevisionsLog", "EvaluatorRevisionsResponse", "EvaluatorTemplate", "EvaluatorTemplatesResponse", "EvaluatorVariant", "EvaluatorVariantCreate", "EvaluatorVariantEdit", "EvaluatorVariantFlags", "EvaluatorVariantFork", "EvaluatorVariantResponse", "EvaluatorVariantsResponse", "EvaluatorsResponse", "Event", "EventQuery", "EventType", "EventsQueryResponse", "ExistenceOperator", "FetchLegacyAnalyticsRequestNewest", "FetchLegacyAnalyticsRequestOldest", "FetchSimpleTestsetToFileRequestFileType", "FetchTestsetRevisionToFileRequestFileType", "FilteringInput", "FilteringInputConditionsItem", "FilteringOutput", "FilteringOutputConditionsItem", "Focus", "Folder", "FolderCreate", "FolderEdit", "FolderIdResponse", "FolderKind", "FolderQuery", "FolderQueryKinds", "FolderResponse", "FoldersResponse", "Format", "Formatting", typing.Any, typing.Any, "GatewayToolConfig", "GatewayToolConfigPermission", "HarnessKind", "Header", "HttpValidationError", "InviteRequest", "Invocation", "InvocationCreate", "InvocationCreateLinks", "InvocationEdit", "InvocationEditLinks", "InvocationLinkResponse", "InvocationLinks", "InvocationQuery", "InvocationQueryLinks", "InvocationResponse", "InvocationsResponse", "JsonSchemasInput", "JsonSchemasOutput", typing.Any, typing.Any, "LegacyLifecycleDto", "ListApiKeysResponse", "ListOperator", "ListOptions", "LogicalOperator", "MetricSpec", "MetricType", "MetricsBucket", "Mount", "MountCreate", "MountCredentials", "MountCredentialsResponse", "MountData", "MountEdit", "MountFileDeletedResponse", "MountFileWrittenResponse", "MountFlags", "MountFolderCreatedResponse", "MountQuery", "MountResponse", "MountsResponse", "NumericOperator", "OTelEventInput", "OTelEventInputTimestamp", "OTelEventOutput", "OTelEventOutputTimestamp", "OTelHashInput", "OTelHashOutput", "OTelLinkInput", "OTelLinkOutput", "OTelLinksResponse", "OTelReferenceInput", "OTelReferenceOutput", "OTelSpanKind", "OTelStatusCode", "OTelTracingRequest", "OTelTracingResponse", "OldAnalyticsResponse", "Organization", "OrganizationDetails", "OrganizationDomainResponse", "OrganizationProviderResponse", "OrganizationUpdate", "Permission", "PlaygroundBuildKitContext", "ProjectsResponse", "QueriesResponse", "Query", "QueryApplicationVariantsRequestOrder", "QueryCreate", "QueryEdit", "QueryEnvironmentRevisionsRequestOrder", "QueryEnvironmentVariantsRequestOrder", "QueryEnvironmentsRequestOrder", "QueryEvaluatorVariantsRequestOrder", "QueryFlags", "QueryQueriesRequestOrder", "QueryQueryFlags", "QueryResponse", "QueryRevision", "QueryRevisionCommit", "QueryRevisionCreate", "QueryRevisionDataInput", "QueryRevisionDataOutput", "QueryRevisionEdit", "QueryRevisionQuery", "QueryRevisionResponse", "QueryRevisionsLog", "QueryRevisionsResponse", "QuerySpansAnalyticsRequestNewest", "QuerySpansAnalyticsRequestOldest", "QueryVariant", "QueryVariantCreate", "QueryVariantEdit", "QueryVariantFork", "QueryVariantQuery", "QueryVariantResponse", "QueryVariantsResponse", "QueryWorkflowRevisionsRequestOrder", "QueryWorkflowVariantsRequestOrder", "QueryWorkflowsRequestOrder", "Reference", "ReferenceRequestModelInput", "ReferenceRequestModelOutput", "RequestType", "ResolutionInfo", "ResolvedTool", "RetrievalInfo", "SecretDto", "SecretDtoData", "SecretKind", "SecretResponseDto", "SecretResponseDtoData", "Selector", "SessionHeartbeatResult", "SessionIdsResponse", "SessionInteraction", "SessionInteractionData", "SessionInteractionFlags", "SessionInteractionKind", "SessionInteractionQuery", "SessionInteractionQueryFlags", "SessionInteractionResponse", "SessionInteractionStatus", "SessionInteractionsResponse", "SessionMount", "SessionMountQuery", "SessionMountsResponse", "SessionRecord", "SessionRecordResponse", "SessionRecordsQueryResponse", "SessionResponse", "SessionStream", "SessionStreamCommandResponse", "SessionStreamFlags", "SessionStreamHeaderEdit", "SessionStreamResponse", "SessionStreamsResponse", "SessionTurn", "SessionTurnQuery", "SessionTurnResponse", "SessionTurnsResponse", "SessionsResponse", "SimpleApplication", "SimpleApplicationAdditionalContext", "SimpleApplicationCreate", "SimpleApplicationDataInput", "SimpleApplicationDataInputHeadersValue", "SimpleApplicationDataInputRuntime", "SimpleApplicationDataOutput", "SimpleApplicationDataOutputHeadersValue", "SimpleApplicationDataOutputRuntime", "SimpleApplicationEdit", "SimpleApplicationFlags", "SimpleApplicationQuery", "SimpleApplicationQueryFlags", "SimpleApplicationResponse", "SimpleApplicationsResponse", "SimpleEnvironment", "SimpleEnvironmentCreate", "SimpleEnvironmentEdit", "SimpleEnvironmentQuery", "SimpleEnvironmentResponse", "SimpleEnvironmentsResponse", "SimpleEvaluation", "SimpleEvaluationCreate", "SimpleEvaluationData", "SimpleEvaluationDataApplicationSteps", "SimpleEvaluationDataApplicationStepsOneValue", "SimpleEvaluationDataEvaluatorSteps", "SimpleEvaluationDataEvaluatorStepsOneValue", "SimpleEvaluationDataQuerySteps", "SimpleEvaluationDataQueryStepsOneValue", "SimpleEvaluationDataTestsetSteps", "SimpleEvaluationDataTestsetStepsOneValue", "SimpleEvaluationEdit", "SimpleEvaluationIdResponse", "SimpleEvaluationQuery", "SimpleEvaluationResponse", "SimpleEvaluationsResponse", "SimpleEvaluator", "SimpleEvaluatorCreate", "SimpleEvaluatorDataInput", "SimpleEvaluatorDataInputHeadersValue", "SimpleEvaluatorDataInputRuntime", "SimpleEvaluatorDataOutput", "SimpleEvaluatorDataOutputHeadersValue", "SimpleEvaluatorDataOutputRuntime", "SimpleEvaluatorEdit", "SimpleEvaluatorFlags", "SimpleEvaluatorQuery", "SimpleEvaluatorQueryFlags", "SimpleEvaluatorResponse", "SimpleEvaluatorsResponse", "SimpleQueriesResponse", "SimpleQuery", "SimpleQueryCreate", "SimpleQueryEdit", "SimpleQueryQuery", "SimpleQueryResponse", "SimpleQueue", "SimpleQueueCreate", "SimpleQueueData", "SimpleQueueDataEvaluators", "SimpleQueueDataEvaluatorsOneValue", "SimpleQueueIdResponse", "SimpleQueueIdsResponse", "SimpleQueueKind", "SimpleQueueQuery", "SimpleQueueResponse", "SimpleQueueScenariosQuery", "SimpleQueueScenariosResponse", "SimpleQueueSettings", "SimpleQueuesResponse", "SimpleTestset", "SimpleTestsetCreate", "SimpleTestsetEdit", "SimpleTestsetQuery", "SimpleTestsetResponse", "SimpleTestsetsResponse", "SimpleTrace", "SimpleTraceChannel", "SimpleTraceCreate", "SimpleTraceCreateLinks", "SimpleTraceEdit", "SimpleTraceEditLinks", "SimpleTraceKind", "SimpleTraceLinkResponse", "SimpleTraceLinks", "SimpleTraceOrigin", "SimpleTraceQuery", "SimpleTraceQueryLinks", "SimpleTraceReferences", "SimpleTraceResponse", "SimpleTracesResponse", "SimpleWorkflow", "SimpleWorkflowCreate", "SimpleWorkflowDataInput", "SimpleWorkflowDataInputHeadersValue", "SimpleWorkflowDataInputRuntime", "SimpleWorkflowDataOutput", "SimpleWorkflowDataOutputHeadersValue", "SimpleWorkflowDataOutputRuntime", "SimpleWorkflowEdit", "SimpleWorkflowFlags", "SimpleWorkflowQuery", "SimpleWorkflowQueryFlags", "SimpleWorkflowResponse", "SimpleWorkflowsResponse", "SpanInput", "SpanInputEndTime", "SpanInputStartTime", "SpanOutput", "SpanOutputEndTime", "SpanOutputStartTime", "SpanResponse", "SpanType", "SpansNodeInput", "SpansNodeInputEndTime", "SpansNodeInputSpansValue", "SpansNodeInputStartTime", "SpansNodeOutput", "SpansNodeOutputEndTime", "SpansNodeOutputSpansValue", "SpansNodeOutputStartTime", "SpansResponse", "SpansTreeInput", "SpansTreeInputSpansValue", "SpansTreeOutput", "SpansTreeOutputSpansValue", "SsoProviderDto", "SsoProviderInfo", "SsoProviderSettingsDto", "SsoProviders", "StandardProviderDto", "StandardProviderKind", "StandardProviderSettingsDto", "Status", "StringOperator", "TestcaseInput", "TestcaseOutput", "TestcaseResponse", "TestcasesResponse", "Testset", "TestsetCreate", "TestsetEdit", "TestsetFlags", "TestsetQuery", "TestsetResponse", "TestsetRevision", "TestsetRevisionCommit", "TestsetRevisionCreate", "TestsetRevisionDataInput", "TestsetRevisionDataOutput", "TestsetRevisionDelta", "TestsetRevisionDeltaColumns", "TestsetRevisionDeltaRows", "TestsetRevisionEdit", "TestsetRevisionQuery", "TestsetRevisionResponse", "TestsetRevisionsLog", "TestsetRevisionsResponse", "TestsetVariant", "TestsetVariantCreate", "TestsetVariantEdit", "TestsetVariantFork", "TestsetVariantQuery", "TestsetVariantResponse", "TestsetVariantsResponse", "TestsetsResponse", "TextOptions", "ToolAuthScheme", "ToolCallData", "ToolCallFunction", "ToolCallResponse", "ToolCatalogAction", "ToolCatalogActionDetails", "ToolCatalogActionResponse", "ToolCatalogActionResponseAction", "ToolCatalogActionsResponse", "ToolCatalogActionsResponseActionsItem", "ToolCatalogCategoriesResponse", "ToolCatalogCategory", "ToolCatalogIntegration", "ToolCatalogIntegrationDetails", "ToolCatalogIntegrationResponse", "ToolCatalogIntegrationResponseIntegration", "ToolCatalogIntegrationsResponse", "ToolCatalogIntegrationsResponseIntegrationsItem", "ToolCatalogProvider", "ToolCatalogProviderDetails", "ToolCatalogProviderResponse", "ToolCatalogProviderResponseProvider", "ToolCatalogProvidersResponse", "ToolCatalogProvidersResponseProvidersItem", "ToolConnection", "ToolConnectionCreate", "ToolConnectionCreateData", "ToolConnectionResponse", "ToolConnectionState", "ToolConnectionStatus", "ToolConnectionsResponse", "ToolProviderKind", "ToolResolveRequestToolsItem", "ToolResolveRequestToolsItem_Builtin", "ToolResolveRequestToolsItem_Gateway", "ToolResolveResponse", "ToolResult", "ToolResultData", "TraceIdResponse", "TraceIdsResponse", "TraceInput", "TraceInputSpansValue", "TraceOutput", "TraceOutputSpansValue", "TraceRequest", "TraceResponse", "TraceType", "TracesRequest", "TracesResponse", "TracingQuery", "TriggerAuthScheme", "TriggerCapabilitiesResult", "TriggerCapability", "TriggerCapabilityConnection", "TriggerCatalogEvent", "TriggerCatalogEventDetails", "TriggerCatalogEventResponse", "TriggerCatalogEventsResponse", "TriggerCatalogIntegration", "TriggerCatalogIntegrationResponse", "TriggerCatalogIntegrationsResponse", "TriggerCatalogProvider", "TriggerCatalogProviderResponse", "TriggerCatalogProvidersResponse", "TriggerConnectAffordance", "TriggerConnection", "TriggerConnectionCreate", "TriggerConnectionCreateData", "TriggerConnectionRequirement", "TriggerConnectionResponse", "TriggerConnectionStatus", "TriggerConnectionsResponse", "TriggerDeliveriesResponse", "TriggerDelivery", "TriggerDeliveryData", "TriggerDeliveryQuery", "TriggerDeliveryResponse", "TriggerDiscoveryConnectionState", "TriggerDiscoveryGuidance", "TriggerEventAck", "TriggerProviderKind", "TriggerSchedule", "TriggerScheduleCreate", "TriggerScheduleData", "TriggerScheduleDataInputsFields", "TriggerScheduleEdit", "TriggerScheduleFlags", "TriggerScheduleQuery", "TriggerScheduleResponse", "TriggerSchedulesResponse", "TriggerSubscription", "TriggerSubscriptionCreate", "TriggerSubscriptionCreateRequest", "TriggerSubscriptionData", "TriggerSubscriptionDataInputsFields", "TriggerSubscriptionEdit", "TriggerSubscriptionFlags", "TriggerSubscriptionQuery", "TriggerSubscriptionResponse", "TriggerSubscriptionsResponse", "UnprocessableEntityError", "UserIdsResponse", "ValidationError", "ValidationErrorLocItem", "WebhookDeliveriesResponse", "WebhookDelivery", "WebhookDeliveryCreate", "WebhookDeliveryData", "WebhookDeliveryQuery", "WebhookDeliveryResponse", "WebhookDeliveryResponseInfo", "WebhookEventType", "WebhookProviderDto", "WebhookProviderSettingsDto", "WebhookSubscription", "WebhookSubscriptionCreate", "WebhookSubscriptionData", "WebhookSubscriptionDataAuthMode", "WebhookSubscriptionEdit", "WebhookSubscriptionFlags", "WebhookSubscriptionQuery", "WebhookSubscriptionResponse", "WebhookSubscriptionTestRequestSubscription", "WebhookSubscriptionsResponse", "Windowing", "WindowingOrder", "Workflow", "WorkflowArtifactFlags", "WorkflowCatalogFlags", "WorkflowCatalogHarness", "WorkflowCatalogHarnessResponse", "WorkflowCatalogHarnessesResponse", "WorkflowCatalogPreset", "WorkflowCatalogPresetResponse", "WorkflowCatalogPresetsResponse", "WorkflowCatalogTemplate", "WorkflowCatalogTemplateResponse", "WorkflowCatalogTemplatesResponse", "WorkflowCatalogType", "WorkflowCatalogTypeResponse", "WorkflowCatalogTypesResponse", "WorkflowCreate", "WorkflowEdit", "WorkflowFlags", "WorkflowRequestData", "WorkflowResponse", "WorkflowRevisionCommit", "WorkflowRevisionCreate", "WorkflowRevisionDataInput", "WorkflowRevisionDataInputHeadersValue", "WorkflowRevisionDataInputRuntime", "WorkflowRevisionDataOutput", "WorkflowRevisionDataOutputHeadersValue", "WorkflowRevisionDataOutputRuntime", "WorkflowRevisionDelta", "WorkflowRevisionEdit", "WorkflowRevisionFlags", "WorkflowRevisionInput", "WorkflowRevisionOutput", "WorkflowRevisionResolveResponse", "WorkflowRevisionResponse", "WorkflowRevisionsLog", "WorkflowRevisionsResponse", "WorkflowVariant", "WorkflowVariantCreate", "WorkflowVariantEdit", "WorkflowVariantFlags", "WorkflowVariantFork", "WorkflowVariantResponse", "WorkflowVariantsResponse", "WorkflowsResponse", "Workspace", "WorkspaceMemberResponse", "WorkspacePermission", "WorkspaceResponse", "access", "annotations", "applications", "billing", "environments", "evaluations", "evaluators", "events", "folders", "invocations", "keys", "legacy", "mounts", "organizations", "projects", "queries", "secrets", "sessions", "status", "testcases", "testsets", "tools", "traces", "triggers", "users", "webhooks", "workflows", "workspaces"] diff --git a/clients/python/agenta_client/mounts/client.py b/clients/python/agenta_client/mounts/client.py index ca866f0a4a..7e1498177a 100644 --- a/clients/python/agenta_client/mounts/client.py +++ b/clients/python/agenta_client/mounts/client.py @@ -62,12 +62,14 @@ def create_mount(self, *, mount: MountCreate, request_options: typing.Optional[R _response = self._raw_client.create_mount(mount=mount, request_options=request_options) return _response.data - def query_mounts(self, *, session_id: typing.Optional[str] = None, include_archived: typing.Optional[bool] = None, mount: typing.Optional[MountQuery] = OMIT, windowing: typing.Optional[Windowing] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> MountsResponse: + def query_mounts(self, *, session_id: typing.Optional[str] = None, agent_id: typing.Optional[str] = None, include_archived: typing.Optional[bool] = None, mount: typing.Optional[MountQuery] = OMIT, windowing: typing.Optional[Windowing] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> MountsResponse: """ Parameters ---------- session_id : typing.Optional[str] + agent_id : typing.Optional[str] + include_archived : typing.Optional[bool] mount : typing.Optional[MountQuery] @@ -91,7 +93,7 @@ def query_mounts(self, *, session_id: typing.Optional[str] = None, include_archi ) client.mounts.query_mounts() """ - _response = self._raw_client.query_mounts(session_id=session_id, include_archived=include_archived, mount=mount, windowing=windowing, request_options=request_options) + _response = self._raw_client.query_mounts(session_id=session_id, agent_id=agent_id, include_archived=include_archived, mount=mount, windowing=windowing, request_options=request_options) return _response.data def sign_agent_mount_credentials(self, *, artifact_id: str, name: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None) -> MountCredentialsResponse: @@ -536,12 +538,14 @@ async def main() -> None: _response = await self._raw_client.create_mount(mount=mount, request_options=request_options) return _response.data - async def query_mounts(self, *, session_id: typing.Optional[str] = None, include_archived: typing.Optional[bool] = None, mount: typing.Optional[MountQuery] = OMIT, windowing: typing.Optional[Windowing] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> MountsResponse: + async def query_mounts(self, *, session_id: typing.Optional[str] = None, agent_id: typing.Optional[str] = None, include_archived: typing.Optional[bool] = None, mount: typing.Optional[MountQuery] = OMIT, windowing: typing.Optional[Windowing] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> MountsResponse: """ Parameters ---------- session_id : typing.Optional[str] + agent_id : typing.Optional[str] + include_archived : typing.Optional[bool] mount : typing.Optional[MountQuery] @@ -573,7 +577,7 @@ async def main() -> None: asyncio.run(main()) """ - _response = await self._raw_client.query_mounts(session_id=session_id, include_archived=include_archived, mount=mount, windowing=windowing, request_options=request_options) + _response = await self._raw_client.query_mounts(session_id=session_id, agent_id=agent_id, include_archived=include_archived, mount=mount, windowing=windowing, request_options=request_options) return _response.data async def sign_agent_mount_credentials(self, *, artifact_id: str, name: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None) -> MountCredentialsResponse: diff --git a/clients/python/agenta_client/mounts/raw_client.py b/clients/python/agenta_client/mounts/raw_client.py index 541ba1cf86..ee1d7801b6 100644 --- a/clients/python/agenta_client/mounts/raw_client.py +++ b/clients/python/agenta_client/mounts/raw_client.py @@ -77,12 +77,14 @@ def create_mount(self, *, mount: MountCreate, request_options: typing.Optional[R raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - def query_mounts(self, *, session_id: typing.Optional[str] = None, include_archived: typing.Optional[bool] = None, mount: typing.Optional[MountQuery] = OMIT, windowing: typing.Optional[Windowing] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[MountsResponse]: + def query_mounts(self, *, session_id: typing.Optional[str] = None, agent_id: typing.Optional[str] = None, include_archived: typing.Optional[bool] = None, mount: typing.Optional[MountQuery] = OMIT, windowing: typing.Optional[Windowing] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[MountsResponse]: """ Parameters ---------- session_id : typing.Optional[str] + agent_id : typing.Optional[str] + include_archived : typing.Optional[bool] mount : typing.Optional[MountQuery] @@ -99,7 +101,7 @@ def query_mounts(self, *, session_id: typing.Optional[str] = None, include_archi """ _response = self._client_wrapper.httpx_client.request( "mounts/query",method="POST", - params={"session_id": session_id, "include_archived": include_archived, } + params={"session_id": session_id, "agent_id": agent_id, "include_archived": include_archived, } , json={ "mount": convert_and_respect_annotation_metadata(object_=mount, annotation=typing.Optional[MountQuery], direction="write"), @@ -768,12 +770,14 @@ async def create_mount(self, *, mount: MountCreate, request_options: typing.Opti raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - async def query_mounts(self, *, session_id: typing.Optional[str] = None, include_archived: typing.Optional[bool] = None, mount: typing.Optional[MountQuery] = OMIT, windowing: typing.Optional[Windowing] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[MountsResponse]: + async def query_mounts(self, *, session_id: typing.Optional[str] = None, agent_id: typing.Optional[str] = None, include_archived: typing.Optional[bool] = None, mount: typing.Optional[MountQuery] = OMIT, windowing: typing.Optional[Windowing] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[MountsResponse]: """ Parameters ---------- session_id : typing.Optional[str] + agent_id : typing.Optional[str] + include_archived : typing.Optional[bool] mount : typing.Optional[MountQuery] @@ -790,7 +794,7 @@ async def query_mounts(self, *, session_id: typing.Optional[str] = None, include """ _response = await self._client_wrapper.httpx_client.request( "mounts/query",method="POST", - params={"session_id": session_id, "include_archived": include_archived, } + params={"session_id": session_id, "agent_id": agent_id, "include_archived": include_archived, } , json={ "mount": convert_and_respect_annotation_metadata(object_=mount, annotation=typing.Optional[MountQuery], direction="write"), diff --git a/clients/python/agenta_client/sessions/client.py b/clients/python/agenta_client/sessions/client.py index 188370cd13..9efd32bd98 100644 --- a/clients/python/agenta_client/sessions/client.py +++ b/clients/python/agenta_client/sessions/client.py @@ -6,9 +6,11 @@ from .. import core from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ..core.request_options import RequestOptions +from ..types.harness_kind import HarnessKind from ..types.mount_credentials_response import MountCredentialsResponse from ..types.mount_file_written_response import MountFileWrittenResponse -from ..types.session_heartbeat_response_model import SessionHeartbeatResponseModel +from ..types.reference import Reference +from ..types.session_heartbeat_result import SessionHeartbeatResult from ..types.session_interaction_data import SessionInteractionData from ..types.session_interaction_flags import SessionInteractionFlags from ..types.session_interaction_kind import SessionInteractionKind @@ -20,12 +22,16 @@ from ..types.session_mounts_response import SessionMountsResponse from ..types.session_record_response import SessionRecordResponse from ..types.session_records_query_response import SessionRecordsQueryResponse -from ..types.session_state_data import SessionStateData -from ..types.session_state_response import SessionStateResponse -from ..types.session_stream_command_response_model import SessionStreamCommandResponseModel -from ..types.session_stream_response_model import SessionStreamResponseModel -from ..types.session_streams_response_model import SessionStreamsResponseModel +from ..types.session_response import SessionResponse +from ..types.session_stream_command_response import SessionStreamCommandResponse +from ..types.session_stream_response import SessionStreamResponse +from ..types.session_streams_response import SessionStreamsResponse +from ..types.session_turn_query import SessionTurnQuery +from ..types.session_turn_response import SessionTurnResponse +from ..types.session_turns_response import SessionTurnsResponse +from ..types.sessions_response import SessionsResponse from ..types.windowing import Windowing +from ..types.workflow_request_data import WorkflowRequestData from .raw_client import AsyncRawSessionsClient, RawSessionsClient # this is used as the default value for optional parameters @@ -45,7 +51,7 @@ def with_raw_response(self) -> RawSessionsClient: """ return self._raw_client - def fetch_session_stream(self, *, session_id: str, request_options: typing.Optional[RequestOptions] = None) -> SessionStreamResponseModel: + def fetch_session_stream(self, *, session_id: str, request_options: typing.Optional[RequestOptions] = None) -> SessionStreamResponse: """ Parameters ---------- @@ -56,7 +62,7 @@ def fetch_session_stream(self, *, session_id: str, request_options: typing.Optio Returns ------- - SessionStreamResponseModel + SessionStreamResponse Successful Response Examples @@ -73,13 +79,13 @@ def fetch_session_stream(self, *, session_id: str, request_options: typing.Optio _response = self._raw_client.fetch_session_stream(session_id=session_id, request_options=request_options) return _response.data - def set_session_stream(self, *, session_id: str, prompt: typing.Optional[str] = OMIT, force: typing.Optional[bool] = OMIT, detached: typing.Optional[bool] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> SessionStreamCommandResponseModel: + def set_session_stream(self, *, session_id: str, data: typing.Optional[WorkflowRequestData] = OMIT, force: typing.Optional[bool] = OMIT, detached: typing.Optional[bool] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> SessionStreamCommandResponse: """ Parameters ---------- session_id : str - prompt : typing.Optional[str] + data : typing.Optional[WorkflowRequestData] force : typing.Optional[bool] @@ -90,7 +96,7 @@ def set_session_stream(self, *, session_id: str, prompt: typing.Optional[str] = Returns ------- - SessionStreamCommandResponseModel + SessionStreamCommandResponse Successful Response Examples @@ -104,7 +110,7 @@ def set_session_stream(self, *, session_id: str, prompt: typing.Optional[str] = session_id="session_id", ) """ - _response = self._raw_client.set_session_stream(session_id=session_id, prompt=prompt, force=force, detached=detached, request_options=request_options) + _response = self._raw_client.set_session_stream(session_id=session_id, data=data, force=force, detached=detached, request_options=request_options) return _response.data def delete_session_stream(self, *, session_id: str, request_options: typing.Optional[RequestOptions] = None) -> typing.Dict[str, typing.Any]: @@ -135,7 +141,7 @@ def delete_session_stream(self, *, session_id: str, request_options: typing.Opti _response = self._raw_client.delete_session_stream(session_id=session_id, request_options=request_options) return _response.data - def query_session_streams(self, *, session_id: typing.Optional[str] = OMIT, is_alive: typing.Optional[bool] = OMIT, is_running: typing.Optional[bool] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> SessionStreamsResponseModel: + def query_session_streams(self, *, session_id: typing.Optional[str] = OMIT, is_alive: typing.Optional[bool] = OMIT, is_running: typing.Optional[bool] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> SessionStreamsResponse: """ Parameters ---------- @@ -150,7 +156,7 @@ def query_session_streams(self, *, session_id: typing.Optional[str] = OMIT, is_a Returns ------- - SessionStreamsResponseModel + SessionStreamsResponse Successful Response Examples @@ -196,7 +202,7 @@ def detach_session_stream(self, *, session_id: str, watcher_id: str, request_opt _response = self._raw_client.detach_session_stream(session_id=session_id, watcher_id=watcher_id, request_options=request_options) return _response.data - def heartbeat_session_stream(self, *, session_id: str, replica_id: str, turn_id: typing.Optional[str] = OMIT, is_running: typing.Optional[bool] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> SessionHeartbeatResponseModel: + def heartbeat_session_stream(self, *, session_id: str, replica_id: str, turn_id: typing.Optional[str] = OMIT, is_running: typing.Optional[bool] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> SessionHeartbeatResult: """ Parameters ---------- @@ -213,7 +219,7 @@ def heartbeat_session_stream(self, *, session_id: str, replica_id: str, turn_id: Returns ------- - SessionHeartbeatResponseModel + SessionHeartbeatResult Successful Response Examples @@ -231,6 +237,38 @@ def heartbeat_session_stream(self, *, session_id: str, replica_id: str, turn_id: _response = self._raw_client.heartbeat_session_stream(session_id=session_id, replica_id=replica_id, turn_id=turn_id, is_running=is_running, request_options=request_options) return _response.data + def set_session_stream_header(self, *, session_id: str, name: typing.Optional[str] = OMIT, description: typing.Optional[str] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> SessionStreamResponse: + """ + Parameters + ---------- + session_id : str + + name : typing.Optional[str] + + description : typing.Optional[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + SessionStreamResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.sessions.set_session_stream_header( + session_id="session_id", + ) + """ + _response = self._raw_client.set_session_stream_header(session_id=session_id, name=name, description=description, request_options=request_options) + return _response.data + def create_interaction(self, *, session_id: str, token: str, kind: SessionInteractionKind, turn_id: typing.Optional[str] = OMIT, data: typing.Optional[SessionInteractionData] = OMIT, flags: typing.Optional[SessionInteractionFlags] = OMIT, tags: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, meta: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> SessionInteractionResponse: """ Parameters @@ -641,7 +679,7 @@ def get_record_event(self, record_id: str, *, request_options: typing.Optional[R _response = self._raw_client.get_record_event(record_id, request_options=request_options) return _response.data - def ingest_record(self, *, session_id: str, record_id: typing.Optional[str] = OMIT, record_index: typing.Optional[int] = OMIT, timestamp: typing.Optional[dt.datetime] = OMIT, record_type: typing.Optional[str] = OMIT, record_source: typing.Optional[str] = OMIT, attributes: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> typing.Dict[str, typing.Any]: + def ingest_record(self, *, session_id: str, record_id: typing.Optional[str] = OMIT, record_index: typing.Optional[int] = OMIT, timestamp: typing.Optional[dt.datetime] = OMIT, record_type: typing.Optional[str] = OMIT, record_source: typing.Optional[str] = OMIT, attributes: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, turn_id: typing.Optional[str] = OMIT, span_id: typing.Optional[str] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> typing.Dict[str, typing.Any]: """ Parameters ---------- @@ -659,6 +697,10 @@ def ingest_record(self, *, session_id: str, record_id: typing.Optional[str] = OM attributes : typing.Optional[typing.Dict[str, typing.Any]] + turn_id : typing.Optional[str] + + span_id : typing.Optional[str] + request_options : typing.Optional[RequestOptions] Request-specific configuration. @@ -678,10 +720,145 @@ def ingest_record(self, *, session_id: str, record_id: typing.Optional[str] = OM session_id="session_id", ) """ - _response = self._raw_client.ingest_record(session_id=session_id, record_id=record_id, record_index=record_index, timestamp=timestamp, record_type=record_type, record_source=record_source, attributes=attributes, request_options=request_options) + _response = self._raw_client.ingest_record(session_id=session_id, record_id=record_id, record_index=record_index, timestamp=timestamp, record_type=record_type, record_source=record_source, attributes=attributes, turn_id=turn_id, span_id=span_id, request_options=request_options) + return _response.data + + def append_turn(self, *, session_id: str, stream_id: str, turn_index: int, harness_kind: HarnessKind, agent_session_id: typing.Optional[str] = OMIT, sandbox_id: typing.Optional[str] = OMIT, references: typing.Optional[typing.Sequence[Reference]] = OMIT, trace_id: typing.Optional[str] = OMIT, span_id: typing.Optional[str] = OMIT, start_time: typing.Optional[dt.datetime] = OMIT, end_time: typing.Optional[dt.datetime] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> SessionTurnResponse: + """ + Parameters + ---------- + session_id : str + + stream_id : str + + turn_index : int + + harness_kind : HarnessKind + + agent_session_id : typing.Optional[str] + + sandbox_id : typing.Optional[str] + + references : typing.Optional[typing.Sequence[Reference]] + + trace_id : typing.Optional[str] + + span_id : typing.Optional[str] + + start_time : typing.Optional[dt.datetime] + + end_time : typing.Optional[dt.datetime] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + SessionTurnResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.sessions.append_turn( + session_id="session_id", + stream_id="stream_id", + turn_index=1, + harness_kind="pi_core", + ) + """ + _response = self._raw_client.append_turn(session_id=session_id, stream_id=stream_id, turn_index=turn_index, harness_kind=harness_kind, agent_session_id=agent_session_id, sandbox_id=sandbox_id, references=references, trace_id=trace_id, span_id=span_id, start_time=start_time, end_time=end_time, request_options=request_options) + return _response.data + + def query_turns(self, *, query: typing.Optional[SessionTurnQuery] = OMIT, windowing: typing.Optional[Windowing] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> SessionTurnsResponse: + """ + Parameters + ---------- + query : typing.Optional[SessionTurnQuery] + + windowing : typing.Optional[Windowing] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + SessionTurnsResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.sessions.query_turns() + """ + _response = self._raw_client.query_turns(query=query, windowing=windowing, request_options=request_options) + return _response.data + + def fetch_turn(self, turn_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> SessionTurnResponse: + """ + Parameters + ---------- + turn_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + SessionTurnResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.sessions.fetch_turn( + turn_id="turn_id", + ) + """ + _response = self._raw_client.fetch_turn(turn_id, request_options=request_options) + return _response.data + + def query_sessions(self, *, references: typing.Optional[typing.Sequence[Reference]] = OMIT, windowing: typing.Optional[Windowing] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> SessionsResponse: + """ + Parameters + ---------- + references : typing.Optional[typing.Sequence[Reference]] + + windowing : typing.Optional[Windowing] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + SessionsResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.sessions.query_sessions() + """ + _response = self._raw_client.query_sessions(references=references, windowing=windowing, request_options=request_options) return _response.data - def get_state(self, *, session_id: str, request_options: typing.Optional[RequestOptions] = None) -> SessionStateResponse: + def delete_session(self, *, session_id: str, request_options: typing.Optional[RequestOptions] = None) -> typing.Dict[str, typing.Any]: """ Parameters ---------- @@ -692,7 +869,7 @@ def get_state(self, *, session_id: str, request_options: typing.Optional[Request Returns ------- - SessionStateResponse + typing.Dict[str, typing.Any] Successful Response Examples @@ -702,34 +879,53 @@ def get_state(self, *, session_id: str, request_options: typing.Optional[Request client = AgentaApi( api_key="YOUR_API_KEY", ) - client.sessions.get_state( + client.sessions.delete_session( session_id="session_id", ) """ - _response = self._raw_client.get_state(session_id=session_id, request_options=request_options) + _response = self._raw_client.delete_session(session_id=session_id, request_options=request_options) return _response.data - def set_state(self, *, session_id: str, data: typing.Optional[SessionStateData] = OMIT, sandbox_id: typing.Optional[str] = OMIT, sandbox_turn_index: typing.Optional[int] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> SessionStateResponse: + def archive_session(self, *, session_id: str, request_options: typing.Optional[RequestOptions] = None) -> SessionResponse: """ Parameters ---------- session_id : str - data : typing.Optional[SessionStateData] - Full replacement of the continuity state (resume ids + staleness guard). + request_options : typing.Optional[RequestOptions] + Request-specific configuration. - sandbox_id : typing.Optional[str] - Remote sandbox id to record alongside the continuity state. + Returns + ------- + SessionResponse + Successful Response - sandbox_turn_index : typing.Optional[int] - the writer's conversation turn index; the pointer write is applied only when it is >= the row's data.latest_turn_index. + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.sessions.archive_session( + session_id="session_id", + ) + """ + _response = self._raw_client.archive_session(session_id=session_id, request_options=request_options) + return _response.data + + def unarchive_session(self, *, session_id: str, request_options: typing.Optional[RequestOptions] = None) -> SessionResponse: + """ + Parameters + ---------- + session_id : str request_options : typing.Optional[RequestOptions] Request-specific configuration. Returns ------- - SessionStateResponse + SessionResponse Successful Response Examples @@ -739,11 +935,11 @@ def set_state(self, *, session_id: str, data: typing.Optional[SessionStateData] client = AgentaApi( api_key="YOUR_API_KEY", ) - client.sessions.set_state( + client.sessions.unarchive_session( session_id="session_id", ) """ - _response = self._raw_client.set_state(session_id=session_id, data=data, sandbox_id=sandbox_id, sandbox_turn_index=sandbox_turn_index, request_options=request_options) + _response = self._raw_client.unarchive_session(session_id=session_id, request_options=request_options) return _response.data class AsyncSessionsClient: def __init__(self, *, client_wrapper: AsyncClientWrapper): @@ -760,7 +956,7 @@ def with_raw_response(self) -> AsyncRawSessionsClient: """ return self._raw_client - async def fetch_session_stream(self, *, session_id: str, request_options: typing.Optional[RequestOptions] = None) -> SessionStreamResponseModel: + async def fetch_session_stream(self, *, session_id: str, request_options: typing.Optional[RequestOptions] = None) -> SessionStreamResponse: """ Parameters ---------- @@ -771,7 +967,7 @@ async def fetch_session_stream(self, *, session_id: str, request_options: typing Returns ------- - SessionStreamResponseModel + SessionStreamResponse Successful Response Examples @@ -796,13 +992,13 @@ async def main() -> None: _response = await self._raw_client.fetch_session_stream(session_id=session_id, request_options=request_options) return _response.data - async def set_session_stream(self, *, session_id: str, prompt: typing.Optional[str] = OMIT, force: typing.Optional[bool] = OMIT, detached: typing.Optional[bool] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> SessionStreamCommandResponseModel: + async def set_session_stream(self, *, session_id: str, data: typing.Optional[WorkflowRequestData] = OMIT, force: typing.Optional[bool] = OMIT, detached: typing.Optional[bool] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> SessionStreamCommandResponse: """ Parameters ---------- session_id : str - prompt : typing.Optional[str] + data : typing.Optional[WorkflowRequestData] force : typing.Optional[bool] @@ -813,7 +1009,7 @@ async def set_session_stream(self, *, session_id: str, prompt: typing.Optional[s Returns ------- - SessionStreamCommandResponseModel + SessionStreamCommandResponse Successful Response Examples @@ -835,7 +1031,7 @@ async def main() -> None: asyncio.run(main()) """ - _response = await self._raw_client.set_session_stream(session_id=session_id, prompt=prompt, force=force, detached=detached, request_options=request_options) + _response = await self._raw_client.set_session_stream(session_id=session_id, data=data, force=force, detached=detached, request_options=request_options) return _response.data async def delete_session_stream(self, *, session_id: str, request_options: typing.Optional[RequestOptions] = None) -> typing.Dict[str, typing.Any]: @@ -874,7 +1070,7 @@ async def main() -> None: _response = await self._raw_client.delete_session_stream(session_id=session_id, request_options=request_options) return _response.data - async def query_session_streams(self, *, session_id: typing.Optional[str] = OMIT, is_alive: typing.Optional[bool] = OMIT, is_running: typing.Optional[bool] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> SessionStreamsResponseModel: + async def query_session_streams(self, *, session_id: typing.Optional[str] = OMIT, is_alive: typing.Optional[bool] = OMIT, is_running: typing.Optional[bool] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> SessionStreamsResponse: """ Parameters ---------- @@ -889,7 +1085,7 @@ async def query_session_streams(self, *, session_id: typing.Optional[str] = OMIT Returns ------- - SessionStreamsResponseModel + SessionStreamsResponse Successful Response Examples @@ -951,7 +1147,7 @@ async def main() -> None: _response = await self._raw_client.detach_session_stream(session_id=session_id, watcher_id=watcher_id, request_options=request_options) return _response.data - async def heartbeat_session_stream(self, *, session_id: str, replica_id: str, turn_id: typing.Optional[str] = OMIT, is_running: typing.Optional[bool] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> SessionHeartbeatResponseModel: + async def heartbeat_session_stream(self, *, session_id: str, replica_id: str, turn_id: typing.Optional[str] = OMIT, is_running: typing.Optional[bool] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> SessionHeartbeatResult: """ Parameters ---------- @@ -968,7 +1164,7 @@ async def heartbeat_session_stream(self, *, session_id: str, replica_id: str, tu Returns ------- - SessionHeartbeatResponseModel + SessionHeartbeatResult Successful Response Examples @@ -994,6 +1190,46 @@ async def main() -> None: _response = await self._raw_client.heartbeat_session_stream(session_id=session_id, replica_id=replica_id, turn_id=turn_id, is_running=is_running, request_options=request_options) return _response.data + async def set_session_stream_header(self, *, session_id: str, name: typing.Optional[str] = OMIT, description: typing.Optional[str] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> SessionStreamResponse: + """ + Parameters + ---------- + session_id : str + + name : typing.Optional[str] + + description : typing.Optional[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + SessionStreamResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.sessions.set_session_stream_header( + session_id="session_id", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.set_session_stream_header(session_id=session_id, name=name, description=description, request_options=request_options) + return _response.data + async def create_interaction(self, *, session_id: str, token: str, kind: SessionInteractionKind, turn_id: typing.Optional[str] = OMIT, data: typing.Optional[SessionInteractionData] = OMIT, flags: typing.Optional[SessionInteractionFlags] = OMIT, tags: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, meta: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> SessionInteractionResponse: """ Parameters @@ -1508,7 +1744,7 @@ async def main() -> None: _response = await self._raw_client.get_record_event(record_id, request_options=request_options) return _response.data - async def ingest_record(self, *, session_id: str, record_id: typing.Optional[str] = OMIT, record_index: typing.Optional[int] = OMIT, timestamp: typing.Optional[dt.datetime] = OMIT, record_type: typing.Optional[str] = OMIT, record_source: typing.Optional[str] = OMIT, attributes: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> typing.Dict[str, typing.Any]: + async def ingest_record(self, *, session_id: str, record_id: typing.Optional[str] = OMIT, record_index: typing.Optional[int] = OMIT, timestamp: typing.Optional[dt.datetime] = OMIT, record_type: typing.Optional[str] = OMIT, record_source: typing.Optional[str] = OMIT, attributes: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, turn_id: typing.Optional[str] = OMIT, span_id: typing.Optional[str] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> typing.Dict[str, typing.Any]: """ Parameters ---------- @@ -1526,6 +1762,10 @@ async def ingest_record(self, *, session_id: str, record_id: typing.Optional[str attributes : typing.Optional[typing.Dict[str, typing.Any]] + turn_id : typing.Optional[str] + + span_id : typing.Optional[str] + request_options : typing.Optional[RequestOptions] Request-specific configuration. @@ -1553,10 +1793,177 @@ async def main() -> None: asyncio.run(main()) """ - _response = await self._raw_client.ingest_record(session_id=session_id, record_id=record_id, record_index=record_index, timestamp=timestamp, record_type=record_type, record_source=record_source, attributes=attributes, request_options=request_options) + _response = await self._raw_client.ingest_record(session_id=session_id, record_id=record_id, record_index=record_index, timestamp=timestamp, record_type=record_type, record_source=record_source, attributes=attributes, turn_id=turn_id, span_id=span_id, request_options=request_options) + return _response.data + + async def append_turn(self, *, session_id: str, stream_id: str, turn_index: int, harness_kind: HarnessKind, agent_session_id: typing.Optional[str] = OMIT, sandbox_id: typing.Optional[str] = OMIT, references: typing.Optional[typing.Sequence[Reference]] = OMIT, trace_id: typing.Optional[str] = OMIT, span_id: typing.Optional[str] = OMIT, start_time: typing.Optional[dt.datetime] = OMIT, end_time: typing.Optional[dt.datetime] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> SessionTurnResponse: + """ + Parameters + ---------- + session_id : str + + stream_id : str + + turn_index : int + + harness_kind : HarnessKind + + agent_session_id : typing.Optional[str] + + sandbox_id : typing.Optional[str] + + references : typing.Optional[typing.Sequence[Reference]] + + trace_id : typing.Optional[str] + + span_id : typing.Optional[str] + + start_time : typing.Optional[dt.datetime] + + end_time : typing.Optional[dt.datetime] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + SessionTurnResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.sessions.append_turn( + session_id="session_id", + stream_id="stream_id", + turn_index=1, + harness_kind="pi_core", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.append_turn(session_id=session_id, stream_id=stream_id, turn_index=turn_index, harness_kind=harness_kind, agent_session_id=agent_session_id, sandbox_id=sandbox_id, references=references, trace_id=trace_id, span_id=span_id, start_time=start_time, end_time=end_time, request_options=request_options) + return _response.data + + async def query_turns(self, *, query: typing.Optional[SessionTurnQuery] = OMIT, windowing: typing.Optional[Windowing] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> SessionTurnsResponse: + """ + Parameters + ---------- + query : typing.Optional[SessionTurnQuery] + + windowing : typing.Optional[Windowing] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + SessionTurnsResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.sessions.query_turns() + + + asyncio.run(main()) + """ + _response = await self._raw_client.query_turns(query=query, windowing=windowing, request_options=request_options) + return _response.data + + async def fetch_turn(self, turn_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> SessionTurnResponse: + """ + Parameters + ---------- + turn_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + SessionTurnResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.sessions.fetch_turn( + turn_id="turn_id", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.fetch_turn(turn_id, request_options=request_options) return _response.data - async def get_state(self, *, session_id: str, request_options: typing.Optional[RequestOptions] = None) -> SessionStateResponse: + async def query_sessions(self, *, references: typing.Optional[typing.Sequence[Reference]] = OMIT, windowing: typing.Optional[Windowing] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> SessionsResponse: + """ + Parameters + ---------- + references : typing.Optional[typing.Sequence[Reference]] + + windowing : typing.Optional[Windowing] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + SessionsResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.sessions.query_sessions() + + + asyncio.run(main()) + """ + _response = await self._raw_client.query_sessions(references=references, windowing=windowing, request_options=request_options) + return _response.data + + async def delete_session(self, *, session_id: str, request_options: typing.Optional[RequestOptions] = None) -> typing.Dict[str, typing.Any]: """ Parameters ---------- @@ -1567,7 +1974,7 @@ async def get_state(self, *, session_id: str, request_options: typing.Optional[R Returns ------- - SessionStateResponse + typing.Dict[str, typing.Any] Successful Response Examples @@ -1582,37 +1989,64 @@ async def get_state(self, *, session_id: str, request_options: typing.Optional[R async def main() -> None: - await client.sessions.get_state( + await client.sessions.delete_session( session_id="session_id", ) asyncio.run(main()) """ - _response = await self._raw_client.get_state(session_id=session_id, request_options=request_options) + _response = await self._raw_client.delete_session(session_id=session_id, request_options=request_options) return _response.data - async def set_state(self, *, session_id: str, data: typing.Optional[SessionStateData] = OMIT, sandbox_id: typing.Optional[str] = OMIT, sandbox_turn_index: typing.Optional[int] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> SessionStateResponse: + async def archive_session(self, *, session_id: str, request_options: typing.Optional[RequestOptions] = None) -> SessionResponse: """ Parameters ---------- session_id : str - data : typing.Optional[SessionStateData] - Full replacement of the continuity state (resume ids + staleness guard). + request_options : typing.Optional[RequestOptions] + Request-specific configuration. - sandbox_id : typing.Optional[str] - Remote sandbox id to record alongside the continuity state. + Returns + ------- + SessionResponse + Successful Response - sandbox_turn_index : typing.Optional[int] - the writer's conversation turn index; the pointer write is applied only when it is >= the row's data.latest_turn_index. + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.sessions.archive_session( + session_id="session_id", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.archive_session(session_id=session_id, request_options=request_options) + return _response.data + + async def unarchive_session(self, *, session_id: str, request_options: typing.Optional[RequestOptions] = None) -> SessionResponse: + """ + Parameters + ---------- + session_id : str request_options : typing.Optional[RequestOptions] Request-specific configuration. Returns ------- - SessionStateResponse + SessionResponse Successful Response Examples @@ -1627,12 +2061,12 @@ async def set_state(self, *, session_id: str, data: typing.Optional[SessionState async def main() -> None: - await client.sessions.set_state( + await client.sessions.unarchive_session( session_id="session_id", ) asyncio.run(main()) """ - _response = await self._raw_client.set_state(session_id=session_id, data=data, sandbox_id=sandbox_id, sandbox_turn_index=sandbox_turn_index, request_options=request_options) + _response = await self._raw_client.unarchive_session(session_id=session_id, request_options=request_options) return _response.data diff --git a/clients/python/agenta_client/sessions/raw_client.py b/clients/python/agenta_client/sessions/raw_client.py index 50d1aa93f1..d70566b474 100644 --- a/clients/python/agenta_client/sessions/raw_client.py +++ b/clients/python/agenta_client/sessions/raw_client.py @@ -13,10 +13,12 @@ from ..core.request_options import RequestOptions from ..core.serialization import convert_and_respect_annotation_metadata from ..errors.unprocessable_entity_error import UnprocessableEntityError +from ..types.harness_kind import HarnessKind from ..types.http_validation_error import HttpValidationError from ..types.mount_credentials_response import MountCredentialsResponse from ..types.mount_file_written_response import MountFileWrittenResponse -from ..types.session_heartbeat_response_model import SessionHeartbeatResponseModel +from ..types.reference import Reference +from ..types.session_heartbeat_result import SessionHeartbeatResult from ..types.session_interaction_data import SessionInteractionData from ..types.session_interaction_flags import SessionInteractionFlags from ..types.session_interaction_kind import SessionInteractionKind @@ -28,12 +30,16 @@ from ..types.session_mounts_response import SessionMountsResponse from ..types.session_record_response import SessionRecordResponse from ..types.session_records_query_response import SessionRecordsQueryResponse -from ..types.session_state_data import SessionStateData -from ..types.session_state_response import SessionStateResponse -from ..types.session_stream_command_response_model import SessionStreamCommandResponseModel -from ..types.session_stream_response_model import SessionStreamResponseModel -from ..types.session_streams_response_model import SessionStreamsResponseModel +from ..types.session_response import SessionResponse +from ..types.session_stream_command_response import SessionStreamCommandResponse +from ..types.session_stream_response import SessionStreamResponse +from ..types.session_streams_response import SessionStreamsResponse +from ..types.session_turn_query import SessionTurnQuery +from ..types.session_turn_response import SessionTurnResponse +from ..types.session_turns_response import SessionTurnsResponse +from ..types.sessions_response import SessionsResponse from ..types.windowing import Windowing +from ..types.workflow_request_data import WorkflowRequestData # this is used as the default value for optional parameters OMIT = typing.cast(typing.Any, ...) @@ -41,7 +47,7 @@ class RawSessionsClient: def __init__(self, *, client_wrapper: SyncClientWrapper): self._client_wrapper = client_wrapper - def fetch_session_stream(self, *, session_id: str, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[SessionStreamResponseModel]: + def fetch_session_stream(self, *, session_id: str, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[SessionStreamResponse]: """ Parameters ---------- @@ -52,7 +58,7 @@ def fetch_session_stream(self, *, session_id: str, request_options: typing.Optio Returns ------- - HttpResponse[SessionStreamResponseModel] + HttpResponse[SessionStreamResponse] Successful Response """ _response = self._client_wrapper.httpx_client.request( @@ -63,9 +69,9 @@ def fetch_session_stream(self, *, session_id: str, request_options: typing.Optio try: if 200 <= _response.status_code < 300: _data = typing.cast( - SessionStreamResponseModel, + SessionStreamResponse, parse_obj_as( - type_ =SessionStreamResponseModel, # type: ignore + type_ =SessionStreamResponse, # type: ignore object_ =_response.json() ) ) @@ -83,13 +89,13 @@ def fetch_session_stream(self, *, session_id: str, request_options: typing.Optio raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - def set_session_stream(self, *, session_id: str, prompt: typing.Optional[str] = OMIT, force: typing.Optional[bool] = OMIT, detached: typing.Optional[bool] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[SessionStreamCommandResponseModel]: + def set_session_stream(self, *, session_id: str, data: typing.Optional[WorkflowRequestData] = OMIT, force: typing.Optional[bool] = OMIT, detached: typing.Optional[bool] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[SessionStreamCommandResponse]: """ Parameters ---------- session_id : str - prompt : typing.Optional[str] + data : typing.Optional[WorkflowRequestData] force : typing.Optional[bool] @@ -100,14 +106,14 @@ def set_session_stream(self, *, session_id: str, prompt: typing.Optional[str] = Returns ------- - HttpResponse[SessionStreamCommandResponseModel] + HttpResponse[SessionStreamCommandResponse] Successful Response """ _response = self._client_wrapper.httpx_client.request( "sessions/streams/",method="POST", json={ "session_id": session_id, - "prompt": prompt, + "data": convert_and_respect_annotation_metadata(object_=data, annotation=typing.Optional[WorkflowRequestData], direction="write"), "force": force, "detached": detached, } @@ -119,9 +125,9 @@ def set_session_stream(self, *, session_id: str, prompt: typing.Optional[str] = try: if 200 <= _response.status_code < 300: _data = typing.cast( - SessionStreamCommandResponseModel, + SessionStreamCommandResponse, parse_obj_as( - type_ =SessionStreamCommandResponseModel, # type: ignore + type_ =SessionStreamCommandResponse, # type: ignore object_ =_response.json() ) ) @@ -181,7 +187,7 @@ def delete_session_stream(self, *, session_id: str, request_options: typing.Opti raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - def query_session_streams(self, *, session_id: typing.Optional[str] = OMIT, is_alive: typing.Optional[bool] = OMIT, is_running: typing.Optional[bool] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[SessionStreamsResponseModel]: + def query_session_streams(self, *, session_id: typing.Optional[str] = OMIT, is_alive: typing.Optional[bool] = OMIT, is_running: typing.Optional[bool] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[SessionStreamsResponse]: """ Parameters ---------- @@ -196,7 +202,7 @@ def query_session_streams(self, *, session_id: typing.Optional[str] = OMIT, is_a Returns ------- - HttpResponse[SessionStreamsResponseModel] + HttpResponse[SessionStreamsResponse] Successful Response """ _response = self._client_wrapper.httpx_client.request( @@ -214,9 +220,9 @@ def query_session_streams(self, *, session_id: typing.Optional[str] = OMIT, is_a try: if 200 <= _response.status_code < 300: _data = typing.cast( - SessionStreamsResponseModel, + SessionStreamsResponse, parse_obj_as( - type_ =SessionStreamsResponseModel, # type: ignore + type_ =SessionStreamsResponse, # type: ignore object_ =_response.json() ) ) @@ -284,7 +290,7 @@ def detach_session_stream(self, *, session_id: str, watcher_id: str, request_opt raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - def heartbeat_session_stream(self, *, session_id: str, replica_id: str, turn_id: typing.Optional[str] = OMIT, is_running: typing.Optional[bool] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[SessionHeartbeatResponseModel]: + def heartbeat_session_stream(self, *, session_id: str, replica_id: str, turn_id: typing.Optional[str] = OMIT, is_running: typing.Optional[bool] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[SessionHeartbeatResult]: """ Parameters ---------- @@ -301,7 +307,7 @@ def heartbeat_session_stream(self, *, session_id: str, replica_id: str, turn_id: Returns ------- - HttpResponse[SessionHeartbeatResponseModel] + HttpResponse[SessionHeartbeatResult] Successful Response """ _response = self._client_wrapper.httpx_client.request( @@ -320,9 +326,63 @@ def heartbeat_session_stream(self, *, session_id: str, replica_id: str, turn_id: try: if 200 <= _response.status_code < 300: _data = typing.cast( - SessionHeartbeatResponseModel, + SessionHeartbeatResult, parse_obj_as( - type_ =SessionHeartbeatResponseModel, # type: ignore + type_ =SessionHeartbeatResult, # type: ignore + object_ =_response.json() + ) + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def set_session_stream_header(self, *, session_id: str, name: typing.Optional[str] = OMIT, description: typing.Optional[str] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[SessionStreamResponse]: + """ + Parameters + ---------- + session_id : str + + name : typing.Optional[str] + + description : typing.Optional[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[SessionStreamResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + "sessions/streams/header",method="PUT", + params={"session_id": session_id, } + , + json={ + "name": name, + "description": description, + } + , + headers={"content-type": "application/json", } + , + request_options=request_options,omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + SessionStreamResponse, + parse_obj_as( + type_ =SessionStreamResponse, # type: ignore object_ =_response.json() ) ) @@ -987,7 +1047,7 @@ def get_record_event(self, record_id: str, *, request_options: typing.Optional[R raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - def ingest_record(self, *, session_id: str, record_id: typing.Optional[str] = OMIT, record_index: typing.Optional[int] = OMIT, timestamp: typing.Optional[dt.datetime] = OMIT, record_type: typing.Optional[str] = OMIT, record_source: typing.Optional[str] = OMIT, attributes: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[typing.Dict[str, typing.Any]]: + def ingest_record(self, *, session_id: str, record_id: typing.Optional[str] = OMIT, record_index: typing.Optional[int] = OMIT, timestamp: typing.Optional[dt.datetime] = OMIT, record_type: typing.Optional[str] = OMIT, record_source: typing.Optional[str] = OMIT, attributes: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, turn_id: typing.Optional[str] = OMIT, span_id: typing.Optional[str] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[typing.Dict[str, typing.Any]]: """ Parameters ---------- @@ -1005,6 +1065,10 @@ def ingest_record(self, *, session_id: str, record_id: typing.Optional[str] = OM attributes : typing.Optional[typing.Dict[str, typing.Any]] + turn_id : typing.Optional[str] + + span_id : typing.Optional[str] + request_options : typing.Optional[RequestOptions] Request-specific configuration. @@ -1023,6 +1087,8 @@ def ingest_record(self, *, session_id: str, record_id: typing.Optional[str] = OM "record_type": record_type, "record_source": record_source, "attributes": attributes, + "turn_id": turn_id, + "span_id": span_id, } , headers={"content-type": "application/json", } @@ -1052,31 +1118,66 @@ def ingest_record(self, *, session_id: str, record_id: typing.Optional[str] = OM raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - def get_state(self, *, session_id: str, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[SessionStateResponse]: + def append_turn(self, *, session_id: str, stream_id: str, turn_index: int, harness_kind: HarnessKind, agent_session_id: typing.Optional[str] = OMIT, sandbox_id: typing.Optional[str] = OMIT, references: typing.Optional[typing.Sequence[Reference]] = OMIT, trace_id: typing.Optional[str] = OMIT, span_id: typing.Optional[str] = OMIT, start_time: typing.Optional[dt.datetime] = OMIT, end_time: typing.Optional[dt.datetime] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[SessionTurnResponse]: """ Parameters ---------- session_id : str + stream_id : str + + turn_index : int + + harness_kind : HarnessKind + + agent_session_id : typing.Optional[str] + + sandbox_id : typing.Optional[str] + + references : typing.Optional[typing.Sequence[Reference]] + + trace_id : typing.Optional[str] + + span_id : typing.Optional[str] + + start_time : typing.Optional[dt.datetime] + + end_time : typing.Optional[dt.datetime] + request_options : typing.Optional[RequestOptions] Request-specific configuration. Returns ------- - HttpResponse[SessionStateResponse] + HttpResponse[SessionTurnResponse] Successful Response """ _response = self._client_wrapper.httpx_client.request( - "sessions/states/",method="GET", - params={"session_id": session_id, } + "sessions/turns/",method="POST", + json={ + "session_id": session_id, + "stream_id": stream_id, + "turn_index": turn_index, + "harness_kind": harness_kind, + "agent_session_id": agent_session_id, + "sandbox_id": sandbox_id, + "references": convert_and_respect_annotation_metadata(object_=references, annotation=typing.Optional[typing.Sequence[Reference]], direction="write"), + "trace_id": trace_id, + "span_id": span_id, + "start_time": start_time, + "end_time": end_time, + } , - request_options=request_options,) + headers={"content-type": "application/json", } + , + request_options=request_options,omit=OMIT, + ) try: if 200 <= _response.status_code < 300: _data = typing.cast( - SessionStateResponse, + SessionTurnResponse, parse_obj_as( - type_ =SessionStateResponse, # type: ignore + type_ =SessionTurnResponse, # type: ignore object_ =_response.json() ) ) @@ -1094,37 +1195,27 @@ def get_state(self, *, session_id: str, request_options: typing.Optional[Request raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - def set_state(self, *, session_id: str, data: typing.Optional[SessionStateData] = OMIT, sandbox_id: typing.Optional[str] = OMIT, sandbox_turn_index: typing.Optional[int] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[SessionStateResponse]: + def query_turns(self, *, query: typing.Optional[SessionTurnQuery] = OMIT, windowing: typing.Optional[Windowing] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[SessionTurnsResponse]: """ Parameters ---------- - session_id : str + query : typing.Optional[SessionTurnQuery] - data : typing.Optional[SessionStateData] - Full replacement of the continuity state (resume ids + staleness guard). - - sandbox_id : typing.Optional[str] - Remote sandbox id to record alongside the continuity state. - - sandbox_turn_index : typing.Optional[int] - the writer's conversation turn index; the pointer write is applied only when it is >= the row's data.latest_turn_index. + windowing : typing.Optional[Windowing] request_options : typing.Optional[RequestOptions] Request-specific configuration. Returns ------- - HttpResponse[SessionStateResponse] + HttpResponse[SessionTurnsResponse] Successful Response """ _response = self._client_wrapper.httpx_client.request( - "sessions/states/",method="PUT", - params={"session_id": session_id, } - , + "sessions/turns/query",method="POST", json={ - "data": convert_and_respect_annotation_metadata(object_=data, annotation=typing.Optional[SessionStateData], direction="write"), - "sandbox_id": sandbox_id, - "sandbox_turn_index": sandbox_turn_index, + "query": convert_and_respect_annotation_metadata(object_=query, annotation=typing.Optional[SessionTurnQuery], direction="write"), + "windowing": convert_and_respect_annotation_metadata(object_=windowing, annotation=typing.Optional[Windowing], direction="write"), } , headers={"content-type": "application/json", } @@ -1134,9 +1225,9 @@ def set_state(self, *, session_id: str, data: typing.Optional[SessionStateData] try: if 200 <= _response.status_code < 300: _data = typing.cast( - SessionStateResponse, + SessionTurnsResponse, parse_obj_as( - type_ =SessionStateResponse, # type: ignore + type_ =SessionTurnsResponse, # type: ignore object_ =_response.json() ) ) @@ -1153,39 +1244,34 @@ def set_state(self, *, session_id: str, data: typing.Optional[SessionStateData] except JSONDecodeError: raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) -class AsyncRawSessionsClient: - def __init__(self, *, client_wrapper: AsyncClientWrapper): - self._client_wrapper = client_wrapper - async def fetch_session_stream(self, *, session_id: str, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[SessionStreamResponseModel]: + def fetch_turn(self, turn_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[SessionTurnResponse]: """ Parameters ---------- - session_id : str + turn_id : str request_options : typing.Optional[RequestOptions] Request-specific configuration. Returns ------- - AsyncHttpResponse[SessionStreamResponseModel] + HttpResponse[SessionTurnResponse] Successful Response """ - _response = await self._client_wrapper.httpx_client.request( - "sessions/streams/",method="GET", - params={"session_id": session_id, } - , + _response = self._client_wrapper.httpx_client.request( + f"sessions/turns/{jsonable_encoder(turn_id)}",method="GET", request_options=request_options,) try: if 200 <= _response.status_code < 300: _data = typing.cast( - SessionStreamResponseModel, + SessionTurnResponse, parse_obj_as( - type_ =SessionStreamResponseModel, # type: ignore + type_ =SessionTurnResponse, # type: ignore object_ =_response.json() ) ) - return AsyncHttpResponse(response=_response, data=_data) + return HttpResponse(response=_response, data=_data) if _response.status_code == 422: raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( HttpValidationError, @@ -1199,33 +1285,27 @@ async def fetch_session_stream(self, *, session_id: str, request_options: typing raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - async def set_session_stream(self, *, session_id: str, prompt: typing.Optional[str] = OMIT, force: typing.Optional[bool] = OMIT, detached: typing.Optional[bool] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[SessionStreamCommandResponseModel]: + def query_sessions(self, *, references: typing.Optional[typing.Sequence[Reference]] = OMIT, windowing: typing.Optional[Windowing] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[SessionsResponse]: """ Parameters ---------- - session_id : str - - prompt : typing.Optional[str] - - force : typing.Optional[bool] + references : typing.Optional[typing.Sequence[Reference]] - detached : typing.Optional[bool] + windowing : typing.Optional[Windowing] request_options : typing.Optional[RequestOptions] Request-specific configuration. Returns ------- - AsyncHttpResponse[SessionStreamCommandResponseModel] + HttpResponse[SessionsResponse] Successful Response """ - _response = await self._client_wrapper.httpx_client.request( - "sessions/streams/",method="POST", + _response = self._client_wrapper.httpx_client.request( + "sessions/query",method="POST", json={ - "session_id": session_id, - "prompt": prompt, - "force": force, - "detached": detached, + "references": convert_and_respect_annotation_metadata(object_=references, annotation=typing.Optional[typing.Sequence[Reference]], direction="write"), + "windowing": convert_and_respect_annotation_metadata(object_=windowing, annotation=typing.Optional[Windowing], direction="write"), } , headers={"content-type": "application/json", } @@ -1235,13 +1315,13 @@ async def set_session_stream(self, *, session_id: str, prompt: typing.Optional[s try: if 200 <= _response.status_code < 300: _data = typing.cast( - SessionStreamCommandResponseModel, + SessionsResponse, parse_obj_as( - type_ =SessionStreamCommandResponseModel, # type: ignore + type_ =SessionsResponse, # type: ignore object_ =_response.json() ) ) - return AsyncHttpResponse(response=_response, data=_data) + return HttpResponse(response=_response, data=_data) if _response.status_code == 422: raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( HttpValidationError, @@ -1255,7 +1335,7 @@ async def set_session_stream(self, *, session_id: str, prompt: typing.Optional[s raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - async def delete_session_stream(self, *, session_id: str, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[typing.Dict[str, typing.Any]]: + def delete_session(self, *, session_id: str, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[typing.Dict[str, typing.Any]]: """ Parameters ---------- @@ -1266,11 +1346,11 @@ async def delete_session_stream(self, *, session_id: str, request_options: typin Returns ------- - AsyncHttpResponse[typing.Dict[str, typing.Any]] + HttpResponse[typing.Dict[str, typing.Any]] Successful Response """ - _response = await self._client_wrapper.httpx_client.request( - "sessions/streams/",method="DELETE", + _response = self._client_wrapper.httpx_client.request( + "sessions/",method="DELETE", params={"session_id": session_id, } , request_options=request_options,) @@ -1283,7 +1363,7 @@ async def delete_session_stream(self, *, session_id: str, request_options: typin object_ =_response.json() ) ) - return AsyncHttpResponse(response=_response, data=_data) + return HttpResponse(response=_response, data=_data) if _response.status_code == 422: raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( HttpValidationError, @@ -1297,46 +1377,35 @@ async def delete_session_stream(self, *, session_id: str, request_options: typin raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - async def query_session_streams(self, *, session_id: typing.Optional[str] = OMIT, is_alive: typing.Optional[bool] = OMIT, is_running: typing.Optional[bool] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[SessionStreamsResponseModel]: + def archive_session(self, *, session_id: str, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[SessionResponse]: """ Parameters ---------- - session_id : typing.Optional[str] - - is_alive : typing.Optional[bool] - - is_running : typing.Optional[bool] + session_id : str request_options : typing.Optional[RequestOptions] Request-specific configuration. Returns ------- - AsyncHttpResponse[SessionStreamsResponseModel] + HttpResponse[SessionResponse] Successful Response """ - _response = await self._client_wrapper.httpx_client.request( - "sessions/streams/query",method="POST", - json={ - "session_id": session_id, - "is_alive": is_alive, - "is_running": is_running, - } - , - headers={"content-type": "application/json", } + _response = self._client_wrapper.httpx_client.request( + "sessions/archive",method="POST", + params={"session_id": session_id, } , - request_options=request_options,omit=OMIT, - ) + request_options=request_options,) try: if 200 <= _response.status_code < 300: _data = typing.cast( - SessionStreamsResponseModel, + SessionResponse, parse_obj_as( - type_ =SessionStreamsResponseModel, # type: ignore + type_ =SessionResponse, # type: ignore object_ =_response.json() ) ) - return AsyncHttpResponse(response=_response, data=_data) + return HttpResponse(response=_response, data=_data) if _response.status_code == 422: raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( HttpValidationError, @@ -1350,43 +1419,35 @@ async def query_session_streams(self, *, session_id: typing.Optional[str] = OMIT raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - async def detach_session_stream(self, *, session_id: str, watcher_id: str, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[typing.Dict[str, typing.Any]]: + def unarchive_session(self, *, session_id: str, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[SessionResponse]: """ Parameters ---------- session_id : str - watcher_id : str - request_options : typing.Optional[RequestOptions] Request-specific configuration. Returns ------- - AsyncHttpResponse[typing.Dict[str, typing.Any]] + HttpResponse[SessionResponse] Successful Response """ - _response = await self._client_wrapper.httpx_client.request( - "sessions/streams/detach",method="POST", - json={ - "session_id": session_id, - "watcher_id": watcher_id, - } - , - headers={"content-type": "application/json", } + _response = self._client_wrapper.httpx_client.request( + "sessions/unarchive",method="POST", + params={"session_id": session_id, } , - request_options=request_options,omit=OMIT, - ) + request_options=request_options,) try: if 200 <= _response.status_code < 300: _data = typing.cast( - typing.Dict[str, typing.Any], + SessionResponse, parse_obj_as( - type_ =typing.Dict[str, typing.Any], # type: ignore + type_ =SessionResponse, # type: ignore object_ =_response.json() ) ) - return AsyncHttpResponse(response=_response, data=_data) + return HttpResponse(response=_response, data=_data) if _response.status_code == 422: raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( HttpValidationError, @@ -1399,46 +1460,35 @@ async def detach_session_stream(self, *, session_id: str, watcher_id: str, reque except JSONDecodeError: raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) +class AsyncRawSessionsClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._client_wrapper = client_wrapper - async def heartbeat_session_stream(self, *, session_id: str, replica_id: str, turn_id: typing.Optional[str] = OMIT, is_running: typing.Optional[bool] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[SessionHeartbeatResponseModel]: + async def fetch_session_stream(self, *, session_id: str, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[SessionStreamResponse]: """ Parameters ---------- session_id : str - replica_id : str - - turn_id : typing.Optional[str] - - is_running : typing.Optional[bool] - request_options : typing.Optional[RequestOptions] Request-specific configuration. Returns ------- - AsyncHttpResponse[SessionHeartbeatResponseModel] + AsyncHttpResponse[SessionStreamResponse] Successful Response """ _response = await self._client_wrapper.httpx_client.request( - "sessions/streams/heartbeat",method="POST", - json={ - "session_id": session_id, - "replica_id": replica_id, - "turn_id": turn_id, - "is_running": is_running, - } - , - headers={"content-type": "application/json", } + "sessions/streams/",method="GET", + params={"session_id": session_id, } , - request_options=request_options,omit=OMIT, - ) + request_options=request_options,) try: if 200 <= _response.status_code < 300: _data = typing.cast( - SessionHeartbeatResponseModel, + SessionStreamResponse, parse_obj_as( - type_ =SessionHeartbeatResponseModel, # type: ignore + type_ =SessionStreamResponse, # type: ignore object_ =_response.json() ) ) @@ -1456,45 +1506,33 @@ async def heartbeat_session_stream(self, *, session_id: str, replica_id: str, tu raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - async def create_interaction(self, *, session_id: str, token: str, kind: SessionInteractionKind, turn_id: typing.Optional[str] = OMIT, data: typing.Optional[SessionInteractionData] = OMIT, flags: typing.Optional[SessionInteractionFlags] = OMIT, tags: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, meta: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[SessionInteractionResponse]: + async def set_session_stream(self, *, session_id: str, data: typing.Optional[WorkflowRequestData] = OMIT, force: typing.Optional[bool] = OMIT, detached: typing.Optional[bool] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[SessionStreamCommandResponse]: """ Parameters ---------- session_id : str - token : str - - kind : SessionInteractionKind - - turn_id : typing.Optional[str] - - data : typing.Optional[SessionInteractionData] - - flags : typing.Optional[SessionInteractionFlags] + data : typing.Optional[WorkflowRequestData] - tags : typing.Optional[typing.Dict[str, typing.Any]] + force : typing.Optional[bool] - meta : typing.Optional[typing.Dict[str, typing.Any]] + detached : typing.Optional[bool] request_options : typing.Optional[RequestOptions] Request-specific configuration. Returns ------- - AsyncHttpResponse[SessionInteractionResponse] + AsyncHttpResponse[SessionStreamCommandResponse] Successful Response """ _response = await self._client_wrapper.httpx_client.request( - "sessions/interactions/",method="POST", + "sessions/streams/",method="POST", json={ "session_id": session_id, - "turn_id": turn_id, - "token": token, - "kind": kind, - "data": convert_and_respect_annotation_metadata(object_=data, annotation=typing.Optional[SessionInteractionData], direction="write"), - "flags": convert_and_respect_annotation_metadata(object_=flags, annotation=SessionInteractionFlags, direction="write"), - "tags": tags, - "meta": meta, + "data": convert_and_respect_annotation_metadata(object_=data, annotation=typing.Optional[WorkflowRequestData], direction="write"), + "force": force, + "detached": detached, } , headers={"content-type": "application/json", } @@ -1504,9 +1542,9 @@ async def create_interaction(self, *, session_id: str, token: str, kind: Session try: if 200 <= _response.status_code < 300: _data = typing.cast( - SessionInteractionResponse, + SessionStreamCommandResponse, parse_obj_as( - type_ =SessionInteractionResponse, # type: ignore + type_ =SessionStreamCommandResponse, # type: ignore object_ =_response.json() ) ) @@ -1524,39 +1562,31 @@ async def create_interaction(self, *, session_id: str, token: str, kind: Session raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - async def query_interactions(self, *, query: typing.Optional[SessionInteractionQuery] = OMIT, windowing: typing.Optional[Windowing] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[SessionInteractionsResponse]: + async def delete_session_stream(self, *, session_id: str, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[typing.Dict[str, typing.Any]]: """ Parameters ---------- - query : typing.Optional[SessionInteractionQuery] - - windowing : typing.Optional[Windowing] + session_id : str request_options : typing.Optional[RequestOptions] Request-specific configuration. Returns ------- - AsyncHttpResponse[SessionInteractionsResponse] + AsyncHttpResponse[typing.Dict[str, typing.Any]] Successful Response """ _response = await self._client_wrapper.httpx_client.request( - "sessions/interactions/query",method="POST", - json={ - "query": convert_and_respect_annotation_metadata(object_=query, annotation=typing.Optional[SessionInteractionQuery], direction="write"), - "windowing": convert_and_respect_annotation_metadata(object_=windowing, annotation=typing.Optional[Windowing], direction="write"), - } - , - headers={"content-type": "application/json", } + "sessions/streams/",method="DELETE", + params={"session_id": session_id, } , - request_options=request_options,omit=OMIT, - ) + request_options=request_options,) try: if 200 <= _response.status_code < 300: _data = typing.cast( - SessionInteractionsResponse, + typing.Dict[str, typing.Any], parse_obj_as( - type_ =SessionInteractionsResponse, # type: ignore + type_ =typing.Dict[str, typing.Any], # type: ignore object_ =_response.json() ) ) @@ -1574,30 +1604,30 @@ async def query_interactions(self, *, query: typing.Optional[SessionInteractionQ raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - async def transition_interaction(self, *, session_id: str, token: str, status: SessionInteractionStatus, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[SessionInteractionResponse]: + async def query_session_streams(self, *, session_id: typing.Optional[str] = OMIT, is_alive: typing.Optional[bool] = OMIT, is_running: typing.Optional[bool] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[SessionStreamsResponse]: """ Parameters ---------- - session_id : str + session_id : typing.Optional[str] - token : str + is_alive : typing.Optional[bool] - status : SessionInteractionStatus + is_running : typing.Optional[bool] request_options : typing.Optional[RequestOptions] Request-specific configuration. Returns ------- - AsyncHttpResponse[SessionInteractionResponse] + AsyncHttpResponse[SessionStreamsResponse] Successful Response """ _response = await self._client_wrapper.httpx_client.request( - "sessions/interactions/transition",method="POST", + "sessions/streams/query",method="POST", json={ "session_id": session_id, - "token": token, - "status": status, + "is_alive": is_alive, + "is_running": is_running, } , headers={"content-type": "application/json", } @@ -1607,9 +1637,9 @@ async def transition_interaction(self, *, session_id: str, token: str, status: S try: if 200 <= _response.status_code < 300: _data = typing.cast( - SessionInteractionResponse, + SessionStreamsResponse, parse_obj_as( - type_ =SessionInteractionResponse, # type: ignore + type_ =SessionStreamsResponse, # type: ignore object_ =_response.json() ) ) @@ -1627,15 +1657,13 @@ async def transition_interaction(self, *, session_id: str, token: str, status: S raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - async def cancel_stale_interactions(self, *, session_id: str, turn_id: str, tokens: typing.Optional[typing.Sequence[str]] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[typing.Dict[str, typing.Any]]: + async def detach_session_stream(self, *, session_id: str, watcher_id: str, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[typing.Dict[str, typing.Any]]: """ Parameters ---------- session_id : str - turn_id : str - - tokens : typing.Optional[typing.Sequence[str]] + watcher_id : str request_options : typing.Optional[RequestOptions] Request-specific configuration. @@ -1646,7 +1674,340 @@ async def cancel_stale_interactions(self, *, session_id: str, turn_id: str, toke Successful Response """ _response = await self._client_wrapper.httpx_client.request( - "sessions/interactions/cancel-stale",method="POST", + "sessions/streams/detach",method="POST", + json={ + "session_id": session_id, + "watcher_id": watcher_id, + } + , + headers={"content-type": "application/json", } + , + request_options=request_options,omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + typing.Dict[str, typing.Any], + parse_obj_as( + type_ =typing.Dict[str, typing.Any], # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def heartbeat_session_stream(self, *, session_id: str, replica_id: str, turn_id: typing.Optional[str] = OMIT, is_running: typing.Optional[bool] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[SessionHeartbeatResult]: + """ + Parameters + ---------- + session_id : str + + replica_id : str + + turn_id : typing.Optional[str] + + is_running : typing.Optional[bool] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[SessionHeartbeatResult] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + "sessions/streams/heartbeat",method="POST", + json={ + "session_id": session_id, + "replica_id": replica_id, + "turn_id": turn_id, + "is_running": is_running, + } + , + headers={"content-type": "application/json", } + , + request_options=request_options,omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + SessionHeartbeatResult, + parse_obj_as( + type_ =SessionHeartbeatResult, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def set_session_stream_header(self, *, session_id: str, name: typing.Optional[str] = OMIT, description: typing.Optional[str] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[SessionStreamResponse]: + """ + Parameters + ---------- + session_id : str + + name : typing.Optional[str] + + description : typing.Optional[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[SessionStreamResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + "sessions/streams/header",method="PUT", + params={"session_id": session_id, } + , + json={ + "name": name, + "description": description, + } + , + headers={"content-type": "application/json", } + , + request_options=request_options,omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + SessionStreamResponse, + parse_obj_as( + type_ =SessionStreamResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def create_interaction(self, *, session_id: str, token: str, kind: SessionInteractionKind, turn_id: typing.Optional[str] = OMIT, data: typing.Optional[SessionInteractionData] = OMIT, flags: typing.Optional[SessionInteractionFlags] = OMIT, tags: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, meta: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[SessionInteractionResponse]: + """ + Parameters + ---------- + session_id : str + + token : str + + kind : SessionInteractionKind + + turn_id : typing.Optional[str] + + data : typing.Optional[SessionInteractionData] + + flags : typing.Optional[SessionInteractionFlags] + + tags : typing.Optional[typing.Dict[str, typing.Any]] + + meta : typing.Optional[typing.Dict[str, typing.Any]] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[SessionInteractionResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + "sessions/interactions/",method="POST", + json={ + "session_id": session_id, + "turn_id": turn_id, + "token": token, + "kind": kind, + "data": convert_and_respect_annotation_metadata(object_=data, annotation=typing.Optional[SessionInteractionData], direction="write"), + "flags": convert_and_respect_annotation_metadata(object_=flags, annotation=SessionInteractionFlags, direction="write"), + "tags": tags, + "meta": meta, + } + , + headers={"content-type": "application/json", } + , + request_options=request_options,omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + SessionInteractionResponse, + parse_obj_as( + type_ =SessionInteractionResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def query_interactions(self, *, query: typing.Optional[SessionInteractionQuery] = OMIT, windowing: typing.Optional[Windowing] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[SessionInteractionsResponse]: + """ + Parameters + ---------- + query : typing.Optional[SessionInteractionQuery] + + windowing : typing.Optional[Windowing] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[SessionInteractionsResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + "sessions/interactions/query",method="POST", + json={ + "query": convert_and_respect_annotation_metadata(object_=query, annotation=typing.Optional[SessionInteractionQuery], direction="write"), + "windowing": convert_and_respect_annotation_metadata(object_=windowing, annotation=typing.Optional[Windowing], direction="write"), + } + , + headers={"content-type": "application/json", } + , + request_options=request_options,omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + SessionInteractionsResponse, + parse_obj_as( + type_ =SessionInteractionsResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def transition_interaction(self, *, session_id: str, token: str, status: SessionInteractionStatus, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[SessionInteractionResponse]: + """ + Parameters + ---------- + session_id : str + + token : str + + status : SessionInteractionStatus + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[SessionInteractionResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + "sessions/interactions/transition",method="POST", + json={ + "session_id": session_id, + "token": token, + "status": status, + } + , + headers={"content-type": "application/json", } + , + request_options=request_options,omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + SessionInteractionResponse, + parse_obj_as( + type_ =SessionInteractionResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def cancel_stale_interactions(self, *, session_id: str, turn_id: str, tokens: typing.Optional[typing.Sequence[str]] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[typing.Dict[str, typing.Any]]: + """ + Parameters + ---------- + session_id : str + + turn_id : str + + tokens : typing.Optional[typing.Sequence[str]] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[typing.Dict[str, typing.Any]] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + "sessions/interactions/cancel-stale",method="POST", json={ "session_id": session_id, "turn_id": turn_id, @@ -2103,7 +2464,7 @@ async def get_record_event(self, record_id: str, *, request_options: typing.Opti raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - async def ingest_record(self, *, session_id: str, record_id: typing.Optional[str] = OMIT, record_index: typing.Optional[int] = OMIT, timestamp: typing.Optional[dt.datetime] = OMIT, record_type: typing.Optional[str] = OMIT, record_source: typing.Optional[str] = OMIT, attributes: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[typing.Dict[str, typing.Any]]: + async def ingest_record(self, *, session_id: str, record_id: typing.Optional[str] = OMIT, record_index: typing.Optional[int] = OMIT, timestamp: typing.Optional[dt.datetime] = OMIT, record_type: typing.Optional[str] = OMIT, record_source: typing.Optional[str] = OMIT, attributes: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, turn_id: typing.Optional[str] = OMIT, span_id: typing.Optional[str] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[typing.Dict[str, typing.Any]]: """ Parameters ---------- @@ -2121,6 +2482,10 @@ async def ingest_record(self, *, session_id: str, record_id: typing.Optional[str attributes : typing.Optional[typing.Dict[str, typing.Any]] + turn_id : typing.Optional[str] + + span_id : typing.Optional[str] + request_options : typing.Optional[RequestOptions] Request-specific configuration. @@ -2139,6 +2504,8 @@ async def ingest_record(self, *, session_id: str, record_id: typing.Optional[str "record_type": record_type, "record_source": record_source, "attributes": attributes, + "turn_id": turn_id, + "span_id": span_id, } , headers={"content-type": "application/json", } @@ -2168,31 +2535,66 @@ async def ingest_record(self, *, session_id: str, record_id: typing.Optional[str raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - async def get_state(self, *, session_id: str, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[SessionStateResponse]: + async def append_turn(self, *, session_id: str, stream_id: str, turn_index: int, harness_kind: HarnessKind, agent_session_id: typing.Optional[str] = OMIT, sandbox_id: typing.Optional[str] = OMIT, references: typing.Optional[typing.Sequence[Reference]] = OMIT, trace_id: typing.Optional[str] = OMIT, span_id: typing.Optional[str] = OMIT, start_time: typing.Optional[dt.datetime] = OMIT, end_time: typing.Optional[dt.datetime] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[SessionTurnResponse]: """ Parameters ---------- session_id : str + stream_id : str + + turn_index : int + + harness_kind : HarnessKind + + agent_session_id : typing.Optional[str] + + sandbox_id : typing.Optional[str] + + references : typing.Optional[typing.Sequence[Reference]] + + trace_id : typing.Optional[str] + + span_id : typing.Optional[str] + + start_time : typing.Optional[dt.datetime] + + end_time : typing.Optional[dt.datetime] + request_options : typing.Optional[RequestOptions] Request-specific configuration. Returns ------- - AsyncHttpResponse[SessionStateResponse] + AsyncHttpResponse[SessionTurnResponse] Successful Response """ _response = await self._client_wrapper.httpx_client.request( - "sessions/states/",method="GET", - params={"session_id": session_id, } + "sessions/turns/",method="POST", + json={ + "session_id": session_id, + "stream_id": stream_id, + "turn_index": turn_index, + "harness_kind": harness_kind, + "agent_session_id": agent_session_id, + "sandbox_id": sandbox_id, + "references": convert_and_respect_annotation_metadata(object_=references, annotation=typing.Optional[typing.Sequence[Reference]], direction="write"), + "trace_id": trace_id, + "span_id": span_id, + "start_time": start_time, + "end_time": end_time, + } , - request_options=request_options,) + headers={"content-type": "application/json", } + , + request_options=request_options,omit=OMIT, + ) try: if 200 <= _response.status_code < 300: _data = typing.cast( - SessionStateResponse, + SessionTurnResponse, parse_obj_as( - type_ =SessionStateResponse, # type: ignore + type_ =SessionTurnResponse, # type: ignore object_ =_response.json() ) ) @@ -2210,37 +2612,117 @@ async def get_state(self, *, session_id: str, request_options: typing.Optional[R raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - async def set_state(self, *, session_id: str, data: typing.Optional[SessionStateData] = OMIT, sandbox_id: typing.Optional[str] = OMIT, sandbox_turn_index: typing.Optional[int] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[SessionStateResponse]: + async def query_turns(self, *, query: typing.Optional[SessionTurnQuery] = OMIT, windowing: typing.Optional[Windowing] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[SessionTurnsResponse]: """ Parameters ---------- - session_id : str + query : typing.Optional[SessionTurnQuery] - data : typing.Optional[SessionStateData] - Full replacement of the continuity state (resume ids + staleness guard). + windowing : typing.Optional[Windowing] - sandbox_id : typing.Optional[str] - Remote sandbox id to record alongside the continuity state. + request_options : typing.Optional[RequestOptions] + Request-specific configuration. - sandbox_turn_index : typing.Optional[int] - the writer's conversation turn index; the pointer write is applied only when it is >= the row's data.latest_turn_index. + Returns + ------- + AsyncHttpResponse[SessionTurnsResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + "sessions/turns/query",method="POST", + json={ + "query": convert_and_respect_annotation_metadata(object_=query, annotation=typing.Optional[SessionTurnQuery], direction="write"), + "windowing": convert_and_respect_annotation_metadata(object_=windowing, annotation=typing.Optional[Windowing], direction="write"), + } + , + headers={"content-type": "application/json", } + , + request_options=request_options,omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + SessionTurnsResponse, + parse_obj_as( + type_ =SessionTurnsResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def fetch_turn(self, turn_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[SessionTurnResponse]: + """ + Parameters + ---------- + turn_id : str request_options : typing.Optional[RequestOptions] Request-specific configuration. Returns ------- - AsyncHttpResponse[SessionStateResponse] + AsyncHttpResponse[SessionTurnResponse] Successful Response """ _response = await self._client_wrapper.httpx_client.request( - "sessions/states/",method="PUT", - params={"session_id": session_id, } - , + f"sessions/turns/{jsonable_encoder(turn_id)}",method="GET", + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + SessionTurnResponse, + parse_obj_as( + type_ =SessionTurnResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def query_sessions(self, *, references: typing.Optional[typing.Sequence[Reference]] = OMIT, windowing: typing.Optional[Windowing] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[SessionsResponse]: + """ + Parameters + ---------- + references : typing.Optional[typing.Sequence[Reference]] + + windowing : typing.Optional[Windowing] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[SessionsResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + "sessions/query",method="POST", json={ - "data": convert_and_respect_annotation_metadata(object_=data, annotation=typing.Optional[SessionStateData], direction="write"), - "sandbox_id": sandbox_id, - "sandbox_turn_index": sandbox_turn_index, + "references": convert_and_respect_annotation_metadata(object_=references, annotation=typing.Optional[typing.Sequence[Reference]], direction="write"), + "windowing": convert_and_respect_annotation_metadata(object_=windowing, annotation=typing.Optional[Windowing], direction="write"), } , headers={"content-type": "application/json", } @@ -2250,9 +2732,135 @@ async def set_state(self, *, session_id: str, data: typing.Optional[SessionState try: if 200 <= _response.status_code < 300: _data = typing.cast( - SessionStateResponse, + SessionsResponse, + parse_obj_as( + type_ =SessionsResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def delete_session(self, *, session_id: str, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[typing.Dict[str, typing.Any]]: + """ + Parameters + ---------- + session_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[typing.Dict[str, typing.Any]] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + "sessions/",method="DELETE", + params={"session_id": session_id, } + , + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + typing.Dict[str, typing.Any], + parse_obj_as( + type_ =typing.Dict[str, typing.Any], # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def archive_session(self, *, session_id: str, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[SessionResponse]: + """ + Parameters + ---------- + session_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[SessionResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + "sessions/archive",method="POST", + params={"session_id": session_id, } + , + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + SessionResponse, + parse_obj_as( + type_ =SessionResponse, # type: ignore + object_ =_response.json() + ) + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( + HttpValidationError, + parse_obj_as( + type_ =HttpValidationError, # type: ignore + object_ =_response.json() + ) + )) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def unarchive_session(self, *, session_id: str, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[SessionResponse]: + """ + Parameters + ---------- + session_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[SessionResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + "sessions/unarchive",method="POST", + params={"session_id": session_id, } + , + request_options=request_options,) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + SessionResponse, parse_obj_as( - type_ =SessionStateResponse, # type: ignore + type_ =SessionResponse, # type: ignore object_ =_response.json() ) ) diff --git a/clients/python/agenta_client/types/__init__.py b/clients/python/agenta_client/types/__init__.py index 957f68737f..298de43177 100644 --- a/clients/python/agenta_client/types/__init__.py +++ b/clients/python/agenta_client/types/__init__.py @@ -307,7 +307,7 @@ from .full_json_output import FullJsonOutput from .gateway_tool_config import GatewayToolConfig from .gateway_tool_config_permission import GatewayToolConfigPermission - from .harness_session_record import HarnessSessionRecord + from .harness_kind import HarnessKind from .header import Header from .http_validation_error import HttpValidationError from .invite_request import InviteRequest @@ -409,7 +409,7 @@ from .secret_response_dto import SecretResponseDto from .secret_response_dto_data import SecretResponseDtoData from .selector import Selector - from .session_heartbeat_response_model import SessionHeartbeatResponseModel + from .session_heartbeat_result import SessionHeartbeatResult from .session_ids_response import SessionIdsResponse from .session_interaction import SessionInteraction from .session_interaction_data import SessionInteractionData @@ -426,16 +426,18 @@ from .session_record import SessionRecord from .session_record_response import SessionRecordResponse from .session_records_query_response import SessionRecordsQueryResponse - from .session_state import SessionState - from .session_state_data import SessionStateData - from .session_state_flags import SessionStateFlags - from .session_state_response import SessionStateResponse - from .session_state_upsert_request import SessionStateUpsertRequest + from .session_response import SessionResponse from .session_stream import SessionStream - from .session_stream_command_response_model import SessionStreamCommandResponseModel + from .session_stream_command_response import SessionStreamCommandResponse from .session_stream_flags import SessionStreamFlags - from .session_stream_response_model import SessionStreamResponseModel - from .session_streams_response_model import SessionStreamsResponseModel + from .session_stream_header_edit import SessionStreamHeaderEdit + from .session_stream_response import SessionStreamResponse + from .session_streams_response import SessionStreamsResponse + from .session_turn import SessionTurn + from .session_turn_query import SessionTurnQuery + from .session_turn_response import SessionTurnResponse + from .session_turns_response import SessionTurnsResponse + from .sessions_response import SessionsResponse from .simple_application import SimpleApplication from .simple_application_additional_context import SimpleApplicationAdditionalContext from .simple_application_create import SimpleApplicationCreate @@ -743,6 +745,7 @@ from .workflow_create import WorkflowCreate from .workflow_edit import WorkflowEdit from .workflow_flags import WorkflowFlags + from .workflow_request_data import WorkflowRequestData from .workflow_response import WorkflowResponse from .workflow_revision_commit import WorkflowRevisionCommit from .workflow_revision_create import WorkflowRevisionCreate @@ -773,7 +776,7 @@ from .workspace_member_response import WorkspaceMemberResponse from .workspace_permission import WorkspacePermission from .workspace_response import WorkspaceResponse -_dynamic_imports: typing.Dict[str, str] = {"AdminAccountCreateOptions": ".admin_account_create_options", "AdminAccountRead": ".admin_account_read", "AdminAccountsCreate": ".admin_accounts_create", "AdminAccountsDelete": ".admin_accounts_delete", "AdminAccountsDeleteTarget": ".admin_accounts_delete_target", "AdminAccountsResponse": ".admin_accounts_response", "AdminApiKeyCreate": ".admin_api_key_create", "AdminApiKeyResponse": ".admin_api_key_response", "AdminDeleteResponse": ".admin_delete_response", "AdminDeletedEntities": ".admin_deleted_entities", "AdminDeletedEntity": ".admin_deleted_entity", "AdminOrganizationCreate": ".admin_organization_create", "AdminOrganizationMembershipCreate": ".admin_organization_membership_create", "AdminOrganizationMembershipRead": ".admin_organization_membership_read", "AdminOrganizationRead": ".admin_organization_read", "AdminProjectCreate": ".admin_project_create", "AdminProjectMembershipCreate": ".admin_project_membership_create", "AdminProjectMembershipRead": ".admin_project_membership_read", "AdminProjectRead": ".admin_project_read", "AdminSimpleAccountCreate": ".admin_simple_account_create", "AdminSimpleAccountDeleteEntry": ".admin_simple_account_delete_entry", "AdminSimpleAccountRead": ".admin_simple_account_read", "AdminSimpleAccountsApiKeysCreate": ".admin_simple_accounts_api_keys_create", "AdminSimpleAccountsCreate": ".admin_simple_accounts_create", "AdminSimpleAccountsDelete": ".admin_simple_accounts_delete", "AdminSimpleAccountsOrganizationsCreate": ".admin_simple_accounts_organizations_create", "AdminSimpleAccountsOrganizationsMembershipsCreate": ".admin_simple_accounts_organizations_memberships_create", "AdminSimpleAccountsOrganizationsTransferOwnership": ".admin_simple_accounts_organizations_transfer_ownership", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjects": ".admin_simple_accounts_organizations_transfer_ownership_include_projects", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjectsZero": ".admin_simple_accounts_organizations_transfer_ownership_include_projects_zero", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspaces": ".admin_simple_accounts_organizations_transfer_ownership_include_workspaces", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspacesZero": ".admin_simple_accounts_organizations_transfer_ownership_include_workspaces_zero", "AdminSimpleAccountsOrganizationsTransferOwnershipResponse": ".admin_simple_accounts_organizations_transfer_ownership_response", "AdminSimpleAccountsProjectsCreate": ".admin_simple_accounts_projects_create", "AdminSimpleAccountsProjectsMembershipsCreate": ".admin_simple_accounts_projects_memberships_create", "AdminSimpleAccountsResponse": ".admin_simple_accounts_response", "AdminSimpleAccountsUsersCreate": ".admin_simple_accounts_users_create", "AdminSimpleAccountsUsersIdentitiesCreate": ".admin_simple_accounts_users_identities_create", "AdminSimpleAccountsUsersResetPassword": ".admin_simple_accounts_users_reset_password", "AdminSimpleAccountsWorkspacesCreate": ".admin_simple_accounts_workspaces_create", "AdminSimpleAccountsWorkspacesMembershipsCreate": ".admin_simple_accounts_workspaces_memberships_create", "AdminStructuredError": ".admin_structured_error", "AdminSubscriptionCreate": ".admin_subscription_create", "AdminSubscriptionRead": ".admin_subscription_read", "AdminUserCreate": ".admin_user_create", "AdminUserIdentityCreate": ".admin_user_identity_create", "AdminUserIdentityRead": ".admin_user_identity_read", "AdminUserIdentityReadStatus": ".admin_user_identity_read_status", "AdminUserRead": ".admin_user_read", "AdminWorkspaceCreate": ".admin_workspace_create", "AdminWorkspaceMembershipCreate": ".admin_workspace_membership_create", "AdminWorkspaceMembershipRead": ".admin_workspace_membership_read", "AdminWorkspaceRead": ".admin_workspace_read", "AgentTemplateOverlay": ".agent_template_overlay", "Analytics": ".analytics", "AnalyticsResponse": ".analytics_response", "Annotation": ".annotation", "AnnotationCreate": ".annotation_create", "AnnotationCreateLinks": ".annotation_create_links", "AnnotationEdit": ".annotation_edit", "AnnotationEditLinks": ".annotation_edit_links", "AnnotationLinkResponse": ".annotation_link_response", "AnnotationLinks": ".annotation_links", "AnnotationQuery": ".annotation_query", "AnnotationQueryLinks": ".annotation_query_links", "AnnotationResponse": ".annotation_response", "AnnotationsResponse": ".annotations_response", "Application": ".application", "ApplicationArtifactFlags": ".application_artifact_flags", "ApplicationArtifactQueryFlags": ".application_artifact_query_flags", "ApplicationCatalogPreset": ".application_catalog_preset", "ApplicationCatalogPresetResponse": ".application_catalog_preset_response", "ApplicationCatalogPresetsResponse": ".application_catalog_presets_response", "ApplicationCatalogTemplate": ".application_catalog_template", "ApplicationCatalogTemplateResponse": ".application_catalog_template_response", "ApplicationCatalogTemplatesResponse": ".application_catalog_templates_response", "ApplicationCatalogType": ".application_catalog_type", "ApplicationCatalogTypesResponse": ".application_catalog_types_response", "ApplicationCreate": ".application_create", "ApplicationEdit": ".application_edit", "ApplicationFlags": ".application_flags", "ApplicationQuery": ".application_query", "ApplicationResponse": ".application_response", "ApplicationRevisionCommit": ".application_revision_commit", "ApplicationRevisionCreate": ".application_revision_create", "ApplicationRevisionDataInput": ".application_revision_data_input", "ApplicationRevisionDataInputHeadersValue": ".application_revision_data_input_headers_value", "ApplicationRevisionDataInputRuntime": ".application_revision_data_input_runtime", "ApplicationRevisionDataOutput": ".application_revision_data_output", "ApplicationRevisionDataOutputHeadersValue": ".application_revision_data_output_headers_value", "ApplicationRevisionDataOutputRuntime": ".application_revision_data_output_runtime", "ApplicationRevisionEdit": ".application_revision_edit", "ApplicationRevisionFlags": ".application_revision_flags", "ApplicationRevisionInput": ".application_revision_input", "ApplicationRevisionOutput": ".application_revision_output", "ApplicationRevisionQuery": ".application_revision_query", "ApplicationRevisionQueryFlags": ".application_revision_query_flags", "ApplicationRevisionResolveResponse": ".application_revision_resolve_response", "ApplicationRevisionResponse": ".application_revision_response", "ApplicationRevisionsLog": ".application_revisions_log", "ApplicationRevisionsResponse": ".application_revisions_response", "ApplicationVariant": ".application_variant", "ApplicationVariantCreate": ".application_variant_create", "ApplicationVariantEdit": ".application_variant_edit", "ApplicationVariantFlags": ".application_variant_flags", "ApplicationVariantFork": ".application_variant_fork", "ApplicationVariantResponse": ".application_variant_response", "ApplicationVariantsResponse": ".application_variants_response", "ApplicationsResponse": ".applications_response", "BodyConfigsFetchVariantsConfigsFetchPost": ".body_configs_fetch_variants_configs_fetch_post", "Bucket": ".bucket", "BuiltinToolConfig": ".builtin_tool_config", "BuiltinToolConfigPermission": ".builtin_tool_config_permission", "CapabilitiesResult": ".capabilities_result", "Capability": ".capability", "CapabilityConnection": ".capability_connection", "CapabilityGuidance": ".capability_guidance", "CollectStatusResponse": ".collect_status_response", "CommandMode": ".command_mode", "ComparisonOperator": ".comparison_operator", "Condition": ".condition", "ConditionOperator": ".condition_operator", "ConditionOptions": ".condition_options", "ConditionValue": ".condition_value", "ConfigResponseModel": ".config_response_model", "ConnectAffordance": ".connect_affordance", "ConnectionRequirement": ".connection_requirement", "CustomModelSettingsDto": ".custom_model_settings_dto", "CustomProviderDto": ".custom_provider_dto", "CustomProviderKind": ".custom_provider_kind", "CustomProviderSettingsDto": ".custom_provider_settings_dto", "CustomSecretDto": ".custom_secret_dto", "CustomSecretFormat": ".custom_secret_format", "CustomSecretSettingsDto": ".custom_secret_settings_dto", "CustomSecretSettingsDtoContent": ".custom_secret_settings_dto_content", "CustomSecretSettingsDtoContentOneValue": ".custom_secret_settings_dto_content_one_value", "DictOperator": ".dict_operator", "DiscoverResponse": ".discover_response", "DiscoverResponseMethodsValue": ".discover_response_methods_value", "DiscoveredAlternative": ".discovered_alternative", "DiscoveredTool": ".discovered_tool", "DiscoveredToolType": ".discovered_tool_type", "DiscoveredTriggerAlternative": ".discovered_trigger_alternative", "DiscoveredTriggerEvent": ".discovered_trigger_event", "DiscoveredTriggerEventType": ".discovered_trigger_event_type", "EntityRef": ".entity_ref", "Environment": ".environment", "EnvironmentCreate": ".environment_create", "EnvironmentEdit": ".environment_edit", "EnvironmentFlags": ".environment_flags", "EnvironmentQueryFlags": ".environment_query_flags", "EnvironmentResponse": ".environment_response", "EnvironmentRevisionCommit": ".environment_revision_commit", "EnvironmentRevisionCreate": ".environment_revision_create", "EnvironmentRevisionData": ".environment_revision_data", "EnvironmentRevisionDelta": ".environment_revision_delta", "EnvironmentRevisionEdit": ".environment_revision_edit", "EnvironmentRevisionInput": ".environment_revision_input", "EnvironmentRevisionOutput": ".environment_revision_output", "EnvironmentRevisionResolveResponse": ".environment_revision_resolve_response", "EnvironmentRevisionResponse": ".environment_revision_response", "EnvironmentRevisionsLog": ".environment_revisions_log", "EnvironmentRevisionsResponse": ".environment_revisions_response", "EnvironmentVariant": ".environment_variant", "EnvironmentVariantCreate": ".environment_variant_create", "EnvironmentVariantEdit": ".environment_variant_edit", "EnvironmentVariantFork": ".environment_variant_fork", "EnvironmentVariantResponse": ".environment_variant_response", "EnvironmentVariantsResponse": ".environment_variants_response", "EnvironmentsResponse": ".environments_response", "ErrorPolicy": ".error_policy", "EvaluationMetrics": ".evaluation_metrics", "EvaluationMetricsCreate": ".evaluation_metrics_create", "EvaluationMetricsIdsResponse": ".evaluation_metrics_ids_response", "EvaluationMetricsQuery": ".evaluation_metrics_query", "EvaluationMetricsQueryScenarioIds": ".evaluation_metrics_query_scenario_ids", "EvaluationMetricsQueryTimestamps": ".evaluation_metrics_query_timestamps", "EvaluationMetricsRefresh": ".evaluation_metrics_refresh", "EvaluationMetricsResponse": ".evaluation_metrics_response", "EvaluationMetricsSetRequest": ".evaluation_metrics_set_request", "EvaluationQueue": ".evaluation_queue", "EvaluationQueueCreate": ".evaluation_queue_create", "EvaluationQueueData": ".evaluation_queue_data", "EvaluationQueueEdit": ".evaluation_queue_edit", "EvaluationQueueFlags": ".evaluation_queue_flags", "EvaluationQueueIdResponse": ".evaluation_queue_id_response", "EvaluationQueueIdsResponse": ".evaluation_queue_ids_response", "EvaluationQueueQuery": ".evaluation_queue_query", "EvaluationQueueQueryFlags": ".evaluation_queue_query_flags", "EvaluationQueueResponse": ".evaluation_queue_response", "EvaluationQueueScenariosQuery": ".evaluation_queue_scenarios_query", "EvaluationQueuesResponse": ".evaluation_queues_response", "EvaluationResult": ".evaluation_result", "EvaluationResultCreate": ".evaluation_result_create", "EvaluationResultIdResponse": ".evaluation_result_id_response", "EvaluationResultIdsResponse": ".evaluation_result_ids_response", "EvaluationResultQuery": ".evaluation_result_query", "EvaluationResultResponse": ".evaluation_result_response", "EvaluationResultsResponse": ".evaluation_results_response", "EvaluationResultsSetRequest": ".evaluation_results_set_request", "EvaluationRun": ".evaluation_run", "EvaluationRunCreate": ".evaluation_run_create", "EvaluationRunDataConcurrency": ".evaluation_run_data_concurrency", "EvaluationRunDataInput": ".evaluation_run_data_input", "EvaluationRunDataMapping": ".evaluation_run_data_mapping", "EvaluationRunDataMappingColumn": ".evaluation_run_data_mapping_column", "EvaluationRunDataMappingStep": ".evaluation_run_data_mapping_step", "EvaluationRunDataOutput": ".evaluation_run_data_output", "EvaluationRunDataStepInput": ".evaluation_run_data_step_input", "EvaluationRunDataStepInputKey": ".evaluation_run_data_step_input_key", "EvaluationRunDataStepInputOrigin": ".evaluation_run_data_step_input_origin", "EvaluationRunDataStepInputType": ".evaluation_run_data_step_input_type", "EvaluationRunDataStepOutput": ".evaluation_run_data_step_output", "EvaluationRunDataStepOutputOrigin": ".evaluation_run_data_step_output_origin", "EvaluationRunDataStepOutputType": ".evaluation_run_data_step_output_type", "EvaluationRunEdit": ".evaluation_run_edit", "EvaluationRunFlags": ".evaluation_run_flags", "EvaluationRunIdResponse": ".evaluation_run_id_response", "EvaluationRunIdsRequest": ".evaluation_run_ids_request", "EvaluationRunIdsResponse": ".evaluation_run_ids_response", "EvaluationRunQuery": ".evaluation_run_query", "EvaluationRunQueryFlags": ".evaluation_run_query_flags", "EvaluationRunResponse": ".evaluation_run_response", "EvaluationRunsResponse": ".evaluation_runs_response", "EvaluationScenario": ".evaluation_scenario", "EvaluationScenarioCreate": ".evaluation_scenario_create", "EvaluationScenarioEdit": ".evaluation_scenario_edit", "EvaluationScenarioIdResponse": ".evaluation_scenario_id_response", "EvaluationScenarioIdsResponse": ".evaluation_scenario_ids_response", "EvaluationScenarioQuery": ".evaluation_scenario_query", "EvaluationScenarioResponse": ".evaluation_scenario_response", "EvaluationScenariosResponse": ".evaluation_scenarios_response", "EvaluationStatus": ".evaluation_status", "Evaluator": ".evaluator", "EvaluatorArtifactFlags": ".evaluator_artifact_flags", "EvaluatorArtifactQueryFlags": ".evaluator_artifact_query_flags", "EvaluatorCatalogPreset": ".evaluator_catalog_preset", "EvaluatorCatalogPresetResponse": ".evaluator_catalog_preset_response", "EvaluatorCatalogPresetsResponse": ".evaluator_catalog_presets_response", "EvaluatorCatalogTemplate": ".evaluator_catalog_template", "EvaluatorCatalogTemplateResponse": ".evaluator_catalog_template_response", "EvaluatorCatalogTemplatesResponse": ".evaluator_catalog_templates_response", "EvaluatorCatalogType": ".evaluator_catalog_type", "EvaluatorCatalogTypesResponse": ".evaluator_catalog_types_response", "EvaluatorCreate": ".evaluator_create", "EvaluatorEdit": ".evaluator_edit", "EvaluatorFlags": ".evaluator_flags", "EvaluatorQuery": ".evaluator_query", "EvaluatorResponse": ".evaluator_response", "EvaluatorRevisionCommit": ".evaluator_revision_commit", "EvaluatorRevisionCreate": ".evaluator_revision_create", "EvaluatorRevisionDataInput": ".evaluator_revision_data_input", "EvaluatorRevisionDataInputHeadersValue": ".evaluator_revision_data_input_headers_value", "EvaluatorRevisionDataInputRuntime": ".evaluator_revision_data_input_runtime", "EvaluatorRevisionDataOutput": ".evaluator_revision_data_output", "EvaluatorRevisionDataOutputHeadersValue": ".evaluator_revision_data_output_headers_value", "EvaluatorRevisionDataOutputRuntime": ".evaluator_revision_data_output_runtime", "EvaluatorRevisionEdit": ".evaluator_revision_edit", "EvaluatorRevisionFlags": ".evaluator_revision_flags", "EvaluatorRevisionInput": ".evaluator_revision_input", "EvaluatorRevisionOutput": ".evaluator_revision_output", "EvaluatorRevisionQuery": ".evaluator_revision_query", "EvaluatorRevisionQueryFlags": ".evaluator_revision_query_flags", "EvaluatorRevisionResolveResponse": ".evaluator_revision_resolve_response", "EvaluatorRevisionResponse": ".evaluator_revision_response", "EvaluatorRevisionsLog": ".evaluator_revisions_log", "EvaluatorRevisionsResponse": ".evaluator_revisions_response", "EvaluatorTemplate": ".evaluator_template", "EvaluatorTemplatesResponse": ".evaluator_templates_response", "EvaluatorVariant": ".evaluator_variant", "EvaluatorVariantCreate": ".evaluator_variant_create", "EvaluatorVariantEdit": ".evaluator_variant_edit", "EvaluatorVariantFlags": ".evaluator_variant_flags", "EvaluatorVariantFork": ".evaluator_variant_fork", "EvaluatorVariantResponse": ".evaluator_variant_response", "EvaluatorVariantsResponse": ".evaluator_variants_response", "EvaluatorsResponse": ".evaluators_response", "Event": ".event", "EventQuery": ".event_query", "EventType": ".event_type", "EventsQueryResponse": ".events_query_response", "ExistenceOperator": ".existence_operator", "FilteringInput": ".filtering_input", "FilteringInputConditionsItem": ".filtering_input_conditions_item", "FilteringOutput": ".filtering_output", "FilteringOutputConditionsItem": ".filtering_output_conditions_item", "Focus": ".focus", "Folder": ".folder", "FolderCreate": ".folder_create", "FolderEdit": ".folder_edit", "FolderIdResponse": ".folder_id_response", "FolderKind": ".folder_kind", "FolderQuery": ".folder_query", "FolderQueryKinds": ".folder_query_kinds", "FolderResponse": ".folder_response", "FoldersResponse": ".folders_response", "Format": ".format", "Formatting": ".formatting", typing.Any: ".full_json_input", typing.Any: ".full_json_output", "GatewayToolConfig": ".gateway_tool_config", "GatewayToolConfigPermission": ".gateway_tool_config_permission", "HarnessSessionRecord": ".harness_session_record", "Header": ".header", "HttpValidationError": ".http_validation_error", "InviteRequest": ".invite_request", "Invocation": ".invocation", "InvocationCreate": ".invocation_create", "InvocationCreateLinks": ".invocation_create_links", "InvocationEdit": ".invocation_edit", "InvocationEditLinks": ".invocation_edit_links", "InvocationLinkResponse": ".invocation_link_response", "InvocationLinks": ".invocation_links", "InvocationQuery": ".invocation_query", "InvocationQueryLinks": ".invocation_query_links", "InvocationResponse": ".invocation_response", "InvocationsResponse": ".invocations_response", "JsonSchemasInput": ".json_schemas_input", "JsonSchemasOutput": ".json_schemas_output", typing.Any: ".label_json_input", typing.Any: ".label_json_output", "LegacyLifecycleDto": ".legacy_lifecycle_dto", "ListApiKeysResponse": ".list_api_keys_response", "ListOperator": ".list_operator", "ListOptions": ".list_options", "LogicalOperator": ".logical_operator", "MetricSpec": ".metric_spec", "MetricType": ".metric_type", "MetricsBucket": ".metrics_bucket", "Mount": ".mount", "MountCreate": ".mount_create", "MountCredentials": ".mount_credentials", "MountCredentialsResponse": ".mount_credentials_response", "MountData": ".mount_data", "MountEdit": ".mount_edit", "MountFileDeletedResponse": ".mount_file_deleted_response", "MountFileWrittenResponse": ".mount_file_written_response", "MountFlags": ".mount_flags", "MountFolderCreatedResponse": ".mount_folder_created_response", "MountQuery": ".mount_query", "MountResponse": ".mount_response", "MountsResponse": ".mounts_response", "NumericOperator": ".numeric_operator", "OTelEventInput": ".o_tel_event_input", "OTelEventInputTimestamp": ".o_tel_event_input_timestamp", "OTelEventOutput": ".o_tel_event_output", "OTelEventOutputTimestamp": ".o_tel_event_output_timestamp", "OTelHashInput": ".o_tel_hash_input", "OTelHashOutput": ".o_tel_hash_output", "OTelLinkInput": ".o_tel_link_input", "OTelLinkOutput": ".o_tel_link_output", "OTelLinksResponse": ".o_tel_links_response", "OTelReferenceInput": ".o_tel_reference_input", "OTelReferenceOutput": ".o_tel_reference_output", "OTelSpanKind": ".o_tel_span_kind", "OTelStatusCode": ".o_tel_status_code", "OTelTracingRequest": ".o_tel_tracing_request", "OTelTracingResponse": ".o_tel_tracing_response", "OldAnalyticsResponse": ".old_analytics_response", "Organization": ".organization", "OrganizationDetails": ".organization_details", "OrganizationDomainResponse": ".organization_domain_response", "OrganizationProviderResponse": ".organization_provider_response", "OrganizationUpdate": ".organization_update", "Permission": ".permission", "PlaygroundBuildKitContext": ".playground_build_kit_context", "ProjectsResponse": ".projects_response", "QueriesResponse": ".queries_response", "Query": ".query", "QueryCreate": ".query_create", "QueryEdit": ".query_edit", "QueryFlags": ".query_flags", "QueryQueryFlags": ".query_query_flags", "QueryResponse": ".query_response", "QueryRevision": ".query_revision", "QueryRevisionCommit": ".query_revision_commit", "QueryRevisionCreate": ".query_revision_create", "QueryRevisionDataInput": ".query_revision_data_input", "QueryRevisionDataOutput": ".query_revision_data_output", "QueryRevisionEdit": ".query_revision_edit", "QueryRevisionQuery": ".query_revision_query", "QueryRevisionResponse": ".query_revision_response", "QueryRevisionsLog": ".query_revisions_log", "QueryRevisionsResponse": ".query_revisions_response", "QueryVariant": ".query_variant", "QueryVariantCreate": ".query_variant_create", "QueryVariantEdit": ".query_variant_edit", "QueryVariantFork": ".query_variant_fork", "QueryVariantQuery": ".query_variant_query", "QueryVariantResponse": ".query_variant_response", "QueryVariantsResponse": ".query_variants_response", "Reference": ".reference", "ReferenceRequestModelInput": ".reference_request_model_input", "ReferenceRequestModelOutput": ".reference_request_model_output", "RequestType": ".request_type", "ResolutionInfo": ".resolution_info", "ResolvedTool": ".resolved_tool", "RetrievalInfo": ".retrieval_info", "SecretDto": ".secret_dto", "SecretDtoData": ".secret_dto_data", "SecretKind": ".secret_kind", "SecretResponseDto": ".secret_response_dto", "SecretResponseDtoData": ".secret_response_dto_data", "Selector": ".selector", "SessionHeartbeatResponseModel": ".session_heartbeat_response_model", "SessionIdsResponse": ".session_ids_response", "SessionInteraction": ".session_interaction", "SessionInteractionData": ".session_interaction_data", "SessionInteractionFlags": ".session_interaction_flags", "SessionInteractionKind": ".session_interaction_kind", "SessionInteractionQuery": ".session_interaction_query", "SessionInteractionQueryFlags": ".session_interaction_query_flags", "SessionInteractionResponse": ".session_interaction_response", "SessionInteractionStatus": ".session_interaction_status", "SessionInteractionsResponse": ".session_interactions_response", "SessionMount": ".session_mount", "SessionMountQuery": ".session_mount_query", "SessionMountsResponse": ".session_mounts_response", "SessionRecord": ".session_record", "SessionRecordResponse": ".session_record_response", "SessionRecordsQueryResponse": ".session_records_query_response", "SessionState": ".session_state", "SessionStateData": ".session_state_data", "SessionStateFlags": ".session_state_flags", "SessionStateResponse": ".session_state_response", "SessionStateUpsertRequest": ".session_state_upsert_request", "SessionStream": ".session_stream", "SessionStreamCommandResponseModel": ".session_stream_command_response_model", "SessionStreamFlags": ".session_stream_flags", "SessionStreamResponseModel": ".session_stream_response_model", "SessionStreamsResponseModel": ".session_streams_response_model", "SimpleApplication": ".simple_application", "SimpleApplicationAdditionalContext": ".simple_application_additional_context", "SimpleApplicationCreate": ".simple_application_create", "SimpleApplicationDataInput": ".simple_application_data_input", "SimpleApplicationDataInputHeadersValue": ".simple_application_data_input_headers_value", "SimpleApplicationDataInputRuntime": ".simple_application_data_input_runtime", "SimpleApplicationDataOutput": ".simple_application_data_output", "SimpleApplicationDataOutputHeadersValue": ".simple_application_data_output_headers_value", "SimpleApplicationDataOutputRuntime": ".simple_application_data_output_runtime", "SimpleApplicationEdit": ".simple_application_edit", "SimpleApplicationFlags": ".simple_application_flags", "SimpleApplicationQuery": ".simple_application_query", "SimpleApplicationQueryFlags": ".simple_application_query_flags", "SimpleApplicationResponse": ".simple_application_response", "SimpleApplicationsResponse": ".simple_applications_response", "SimpleEnvironment": ".simple_environment", "SimpleEnvironmentCreate": ".simple_environment_create", "SimpleEnvironmentEdit": ".simple_environment_edit", "SimpleEnvironmentQuery": ".simple_environment_query", "SimpleEnvironmentResponse": ".simple_environment_response", "SimpleEnvironmentsResponse": ".simple_environments_response", "SimpleEvaluation": ".simple_evaluation", "SimpleEvaluationCreate": ".simple_evaluation_create", "SimpleEvaluationData": ".simple_evaluation_data", "SimpleEvaluationDataApplicationSteps": ".simple_evaluation_data_application_steps", "SimpleEvaluationDataApplicationStepsOneValue": ".simple_evaluation_data_application_steps_one_value", "SimpleEvaluationDataEvaluatorSteps": ".simple_evaluation_data_evaluator_steps", "SimpleEvaluationDataEvaluatorStepsOneValue": ".simple_evaluation_data_evaluator_steps_one_value", "SimpleEvaluationDataQuerySteps": ".simple_evaluation_data_query_steps", "SimpleEvaluationDataQueryStepsOneValue": ".simple_evaluation_data_query_steps_one_value", "SimpleEvaluationDataTestsetSteps": ".simple_evaluation_data_testset_steps", "SimpleEvaluationDataTestsetStepsOneValue": ".simple_evaluation_data_testset_steps_one_value", "SimpleEvaluationEdit": ".simple_evaluation_edit", "SimpleEvaluationIdResponse": ".simple_evaluation_id_response", "SimpleEvaluationQuery": ".simple_evaluation_query", "SimpleEvaluationResponse": ".simple_evaluation_response", "SimpleEvaluationsResponse": ".simple_evaluations_response", "SimpleEvaluator": ".simple_evaluator", "SimpleEvaluatorCreate": ".simple_evaluator_create", "SimpleEvaluatorDataInput": ".simple_evaluator_data_input", "SimpleEvaluatorDataInputHeadersValue": ".simple_evaluator_data_input_headers_value", "SimpleEvaluatorDataInputRuntime": ".simple_evaluator_data_input_runtime", "SimpleEvaluatorDataOutput": ".simple_evaluator_data_output", "SimpleEvaluatorDataOutputHeadersValue": ".simple_evaluator_data_output_headers_value", "SimpleEvaluatorDataOutputRuntime": ".simple_evaluator_data_output_runtime", "SimpleEvaluatorEdit": ".simple_evaluator_edit", "SimpleEvaluatorFlags": ".simple_evaluator_flags", "SimpleEvaluatorQuery": ".simple_evaluator_query", "SimpleEvaluatorQueryFlags": ".simple_evaluator_query_flags", "SimpleEvaluatorResponse": ".simple_evaluator_response", "SimpleEvaluatorsResponse": ".simple_evaluators_response", "SimpleQueriesResponse": ".simple_queries_response", "SimpleQuery": ".simple_query", "SimpleQueryCreate": ".simple_query_create", "SimpleQueryEdit": ".simple_query_edit", "SimpleQueryQuery": ".simple_query_query", "SimpleQueryResponse": ".simple_query_response", "SimpleQueue": ".simple_queue", "SimpleQueueCreate": ".simple_queue_create", "SimpleQueueData": ".simple_queue_data", "SimpleQueueDataEvaluators": ".simple_queue_data_evaluators", "SimpleQueueDataEvaluatorsOneValue": ".simple_queue_data_evaluators_one_value", "SimpleQueueIdResponse": ".simple_queue_id_response", "SimpleQueueIdsResponse": ".simple_queue_ids_response", "SimpleQueueKind": ".simple_queue_kind", "SimpleQueueQuery": ".simple_queue_query", "SimpleQueueResponse": ".simple_queue_response", "SimpleQueueScenariosQuery": ".simple_queue_scenarios_query", "SimpleQueueScenariosResponse": ".simple_queue_scenarios_response", "SimpleQueueSettings": ".simple_queue_settings", "SimpleQueuesResponse": ".simple_queues_response", "SimpleTestset": ".simple_testset", "SimpleTestsetCreate": ".simple_testset_create", "SimpleTestsetEdit": ".simple_testset_edit", "SimpleTestsetQuery": ".simple_testset_query", "SimpleTestsetResponse": ".simple_testset_response", "SimpleTestsetsResponse": ".simple_testsets_response", "SimpleTrace": ".simple_trace", "SimpleTraceChannel": ".simple_trace_channel", "SimpleTraceCreate": ".simple_trace_create", "SimpleTraceCreateLinks": ".simple_trace_create_links", "SimpleTraceEdit": ".simple_trace_edit", "SimpleTraceEditLinks": ".simple_trace_edit_links", "SimpleTraceKind": ".simple_trace_kind", "SimpleTraceLinkResponse": ".simple_trace_link_response", "SimpleTraceLinks": ".simple_trace_links", "SimpleTraceOrigin": ".simple_trace_origin", "SimpleTraceQuery": ".simple_trace_query", "SimpleTraceQueryLinks": ".simple_trace_query_links", "SimpleTraceReferences": ".simple_trace_references", "SimpleTraceResponse": ".simple_trace_response", "SimpleTracesResponse": ".simple_traces_response", "SimpleWorkflow": ".simple_workflow", "SimpleWorkflowCreate": ".simple_workflow_create", "SimpleWorkflowDataInput": ".simple_workflow_data_input", "SimpleWorkflowDataInputHeadersValue": ".simple_workflow_data_input_headers_value", "SimpleWorkflowDataInputRuntime": ".simple_workflow_data_input_runtime", "SimpleWorkflowDataOutput": ".simple_workflow_data_output", "SimpleWorkflowDataOutputHeadersValue": ".simple_workflow_data_output_headers_value", "SimpleWorkflowDataOutputRuntime": ".simple_workflow_data_output_runtime", "SimpleWorkflowEdit": ".simple_workflow_edit", "SimpleWorkflowFlags": ".simple_workflow_flags", "SimpleWorkflowQuery": ".simple_workflow_query", "SimpleWorkflowQueryFlags": ".simple_workflow_query_flags", "SimpleWorkflowResponse": ".simple_workflow_response", "SimpleWorkflowsResponse": ".simple_workflows_response", "SpanInput": ".span_input", "SpanInputEndTime": ".span_input_end_time", "SpanInputStartTime": ".span_input_start_time", "SpanOutput": ".span_output", "SpanOutputEndTime": ".span_output_end_time", "SpanOutputStartTime": ".span_output_start_time", "SpanResponse": ".span_response", "SpanType": ".span_type", "SpansNodeInput": ".spans_node_input", "SpansNodeInputEndTime": ".spans_node_input_end_time", "SpansNodeInputSpansValue": ".spans_node_input_spans_value", "SpansNodeInputStartTime": ".spans_node_input_start_time", "SpansNodeOutput": ".spans_node_output", "SpansNodeOutputEndTime": ".spans_node_output_end_time", "SpansNodeOutputSpansValue": ".spans_node_output_spans_value", "SpansNodeOutputStartTime": ".spans_node_output_start_time", "SpansResponse": ".spans_response", "SpansTreeInput": ".spans_tree_input", "SpansTreeInputSpansValue": ".spans_tree_input_spans_value", "SpansTreeOutput": ".spans_tree_output", "SpansTreeOutputSpansValue": ".spans_tree_output_spans_value", "SsoProviderDto": ".sso_provider_dto", "SsoProviderInfo": ".sso_provider_info", "SsoProviderSettingsDto": ".sso_provider_settings_dto", "SsoProviders": ".sso_providers", "StandardProviderDto": ".standard_provider_dto", "StandardProviderKind": ".standard_provider_kind", "StandardProviderSettingsDto": ".standard_provider_settings_dto", "Status": ".status", "StringOperator": ".string_operator", "TestcaseInput": ".testcase_input", "TestcaseOutput": ".testcase_output", "TestcaseResponse": ".testcase_response", "TestcasesResponse": ".testcases_response", "Testset": ".testset", "TestsetCreate": ".testset_create", "TestsetEdit": ".testset_edit", "TestsetFlags": ".testset_flags", "TestsetQuery": ".testset_query", "TestsetResponse": ".testset_response", "TestsetRevision": ".testset_revision", "TestsetRevisionCommit": ".testset_revision_commit", "TestsetRevisionCreate": ".testset_revision_create", "TestsetRevisionDataInput": ".testset_revision_data_input", "TestsetRevisionDataOutput": ".testset_revision_data_output", "TestsetRevisionDelta": ".testset_revision_delta", "TestsetRevisionDeltaColumns": ".testset_revision_delta_columns", "TestsetRevisionDeltaRows": ".testset_revision_delta_rows", "TestsetRevisionEdit": ".testset_revision_edit", "TestsetRevisionQuery": ".testset_revision_query", "TestsetRevisionResponse": ".testset_revision_response", "TestsetRevisionsLog": ".testset_revisions_log", "TestsetRevisionsResponse": ".testset_revisions_response", "TestsetVariant": ".testset_variant", "TestsetVariantCreate": ".testset_variant_create", "TestsetVariantEdit": ".testset_variant_edit", "TestsetVariantFork": ".testset_variant_fork", "TestsetVariantQuery": ".testset_variant_query", "TestsetVariantResponse": ".testset_variant_response", "TestsetVariantsResponse": ".testset_variants_response", "TestsetsResponse": ".testsets_response", "TextOptions": ".text_options", "ToolAuthScheme": ".tool_auth_scheme", "ToolCallData": ".tool_call_data", "ToolCallFunction": ".tool_call_function", "ToolCallResponse": ".tool_call_response", "ToolCatalogAction": ".tool_catalog_action", "ToolCatalogActionDetails": ".tool_catalog_action_details", "ToolCatalogActionResponse": ".tool_catalog_action_response", "ToolCatalogActionResponseAction": ".tool_catalog_action_response_action", "ToolCatalogActionsResponse": ".tool_catalog_actions_response", "ToolCatalogActionsResponseActionsItem": ".tool_catalog_actions_response_actions_item", "ToolCatalogCategoriesResponse": ".tool_catalog_categories_response", "ToolCatalogCategory": ".tool_catalog_category", "ToolCatalogIntegration": ".tool_catalog_integration", "ToolCatalogIntegrationDetails": ".tool_catalog_integration_details", "ToolCatalogIntegrationResponse": ".tool_catalog_integration_response", "ToolCatalogIntegrationResponseIntegration": ".tool_catalog_integration_response_integration", "ToolCatalogIntegrationsResponse": ".tool_catalog_integrations_response", "ToolCatalogIntegrationsResponseIntegrationsItem": ".tool_catalog_integrations_response_integrations_item", "ToolCatalogProvider": ".tool_catalog_provider", "ToolCatalogProviderDetails": ".tool_catalog_provider_details", "ToolCatalogProviderResponse": ".tool_catalog_provider_response", "ToolCatalogProviderResponseProvider": ".tool_catalog_provider_response_provider", "ToolCatalogProvidersResponse": ".tool_catalog_providers_response", "ToolCatalogProvidersResponseProvidersItem": ".tool_catalog_providers_response_providers_item", "ToolConnection": ".tool_connection", "ToolConnectionCreate": ".tool_connection_create", "ToolConnectionCreateData": ".tool_connection_create_data", "ToolConnectionResponse": ".tool_connection_response", "ToolConnectionState": ".tool_connection_state", "ToolConnectionStatus": ".tool_connection_status", "ToolConnectionsResponse": ".tool_connections_response", "ToolProviderKind": ".tool_provider_kind", "ToolResolveResponse": ".tool_resolve_response", "ToolResult": ".tool_result", "ToolResultData": ".tool_result_data", "TraceIdResponse": ".trace_id_response", "TraceIdsResponse": ".trace_ids_response", "TraceInput": ".trace_input", "TraceInputSpansValue": ".trace_input_spans_value", "TraceOutput": ".trace_output", "TraceOutputSpansValue": ".trace_output_spans_value", "TraceRequest": ".trace_request", "TraceResponse": ".trace_response", "TraceType": ".trace_type", "TracesRequest": ".traces_request", "TracesResponse": ".traces_response", "TracingQuery": ".tracing_query", "TriggerAuthScheme": ".trigger_auth_scheme", "TriggerCapabilitiesResult": ".trigger_capabilities_result", "TriggerCapability": ".trigger_capability", "TriggerCapabilityConnection": ".trigger_capability_connection", "TriggerCatalogEvent": ".trigger_catalog_event", "TriggerCatalogEventDetails": ".trigger_catalog_event_details", "TriggerCatalogEventResponse": ".trigger_catalog_event_response", "TriggerCatalogEventsResponse": ".trigger_catalog_events_response", "TriggerCatalogIntegration": ".trigger_catalog_integration", "TriggerCatalogIntegrationResponse": ".trigger_catalog_integration_response", "TriggerCatalogIntegrationsResponse": ".trigger_catalog_integrations_response", "TriggerCatalogProvider": ".trigger_catalog_provider", "TriggerCatalogProviderResponse": ".trigger_catalog_provider_response", "TriggerCatalogProvidersResponse": ".trigger_catalog_providers_response", "TriggerConnectAffordance": ".trigger_connect_affordance", "TriggerConnection": ".trigger_connection", "TriggerConnectionCreate": ".trigger_connection_create", "TriggerConnectionCreateData": ".trigger_connection_create_data", "TriggerConnectionRequirement": ".trigger_connection_requirement", "TriggerConnectionResponse": ".trigger_connection_response", "TriggerConnectionStatus": ".trigger_connection_status", "TriggerConnectionsResponse": ".trigger_connections_response", "TriggerDeliveriesResponse": ".trigger_deliveries_response", "TriggerDelivery": ".trigger_delivery", "TriggerDeliveryData": ".trigger_delivery_data", "TriggerDeliveryQuery": ".trigger_delivery_query", "TriggerDeliveryResponse": ".trigger_delivery_response", "TriggerDiscoveryConnectionState": ".trigger_discovery_connection_state", "TriggerDiscoveryGuidance": ".trigger_discovery_guidance", "TriggerEventAck": ".trigger_event_ack", "TriggerProviderKind": ".trigger_provider_kind", "TriggerSchedule": ".trigger_schedule", "TriggerScheduleCreate": ".trigger_schedule_create", "TriggerScheduleData": ".trigger_schedule_data", "TriggerScheduleDataInputsFields": ".trigger_schedule_data_inputs_fields", "TriggerScheduleEdit": ".trigger_schedule_edit", "TriggerScheduleFlags": ".trigger_schedule_flags", "TriggerScheduleQuery": ".trigger_schedule_query", "TriggerScheduleResponse": ".trigger_schedule_response", "TriggerSchedulesResponse": ".trigger_schedules_response", "TriggerSubscription": ".trigger_subscription", "TriggerSubscriptionCreate": ".trigger_subscription_create", "TriggerSubscriptionCreateRequest": ".trigger_subscription_create_request", "TriggerSubscriptionData": ".trigger_subscription_data", "TriggerSubscriptionDataInputsFields": ".trigger_subscription_data_inputs_fields", "TriggerSubscriptionEdit": ".trigger_subscription_edit", "TriggerSubscriptionFlags": ".trigger_subscription_flags", "TriggerSubscriptionQuery": ".trigger_subscription_query", "TriggerSubscriptionResponse": ".trigger_subscription_response", "TriggerSubscriptionsResponse": ".trigger_subscriptions_response", "UserIdsResponse": ".user_ids_response", "ValidationError": ".validation_error", "ValidationErrorLocItem": ".validation_error_loc_item", "WebhookDeliveriesResponse": ".webhook_deliveries_response", "WebhookDelivery": ".webhook_delivery", "WebhookDeliveryCreate": ".webhook_delivery_create", "WebhookDeliveryData": ".webhook_delivery_data", "WebhookDeliveryQuery": ".webhook_delivery_query", "WebhookDeliveryResponse": ".webhook_delivery_response", "WebhookDeliveryResponseInfo": ".webhook_delivery_response_info", "WebhookEventType": ".webhook_event_type", "WebhookProviderDto": ".webhook_provider_dto", "WebhookProviderSettingsDto": ".webhook_provider_settings_dto", "WebhookSubscription": ".webhook_subscription", "WebhookSubscriptionCreate": ".webhook_subscription_create", "WebhookSubscriptionData": ".webhook_subscription_data", "WebhookSubscriptionDataAuthMode": ".webhook_subscription_data_auth_mode", "WebhookSubscriptionEdit": ".webhook_subscription_edit", "WebhookSubscriptionFlags": ".webhook_subscription_flags", "WebhookSubscriptionQuery": ".webhook_subscription_query", "WebhookSubscriptionResponse": ".webhook_subscription_response", "WebhookSubscriptionsResponse": ".webhook_subscriptions_response", "Windowing": ".windowing", "WindowingOrder": ".windowing_order", "Workflow": ".workflow", "WorkflowArtifactFlags": ".workflow_artifact_flags", "WorkflowCatalogFlags": ".workflow_catalog_flags", "WorkflowCatalogHarness": ".workflow_catalog_harness", "WorkflowCatalogHarnessResponse": ".workflow_catalog_harness_response", "WorkflowCatalogHarnessesResponse": ".workflow_catalog_harnesses_response", "WorkflowCatalogPreset": ".workflow_catalog_preset", "WorkflowCatalogPresetResponse": ".workflow_catalog_preset_response", "WorkflowCatalogPresetsResponse": ".workflow_catalog_presets_response", "WorkflowCatalogTemplate": ".workflow_catalog_template", "WorkflowCatalogTemplateResponse": ".workflow_catalog_template_response", "WorkflowCatalogTemplatesResponse": ".workflow_catalog_templates_response", "WorkflowCatalogType": ".workflow_catalog_type", "WorkflowCatalogTypeResponse": ".workflow_catalog_type_response", "WorkflowCatalogTypesResponse": ".workflow_catalog_types_response", "WorkflowCreate": ".workflow_create", "WorkflowEdit": ".workflow_edit", "WorkflowFlags": ".workflow_flags", "WorkflowResponse": ".workflow_response", "WorkflowRevisionCommit": ".workflow_revision_commit", "WorkflowRevisionCreate": ".workflow_revision_create", "WorkflowRevisionDataInput": ".workflow_revision_data_input", "WorkflowRevisionDataInputHeadersValue": ".workflow_revision_data_input_headers_value", "WorkflowRevisionDataInputRuntime": ".workflow_revision_data_input_runtime", "WorkflowRevisionDataOutput": ".workflow_revision_data_output", "WorkflowRevisionDataOutputHeadersValue": ".workflow_revision_data_output_headers_value", "WorkflowRevisionDataOutputRuntime": ".workflow_revision_data_output_runtime", "WorkflowRevisionDelta": ".workflow_revision_delta", "WorkflowRevisionEdit": ".workflow_revision_edit", "WorkflowRevisionFlags": ".workflow_revision_flags", "WorkflowRevisionInput": ".workflow_revision_input", "WorkflowRevisionOutput": ".workflow_revision_output", "WorkflowRevisionResolveResponse": ".workflow_revision_resolve_response", "WorkflowRevisionResponse": ".workflow_revision_response", "WorkflowRevisionsLog": ".workflow_revisions_log", "WorkflowRevisionsResponse": ".workflow_revisions_response", "WorkflowVariant": ".workflow_variant", "WorkflowVariantCreate": ".workflow_variant_create", "WorkflowVariantEdit": ".workflow_variant_edit", "WorkflowVariantFlags": ".workflow_variant_flags", "WorkflowVariantFork": ".workflow_variant_fork", "WorkflowVariantResponse": ".workflow_variant_response", "WorkflowVariantsResponse": ".workflow_variants_response", "WorkflowsResponse": ".workflows_response", "Workspace": ".workspace", "WorkspaceMemberResponse": ".workspace_member_response", "WorkspacePermission": ".workspace_permission", "WorkspaceResponse": ".workspace_response"} +_dynamic_imports: typing.Dict[str, str] = {"AdminAccountCreateOptions": ".admin_account_create_options", "AdminAccountRead": ".admin_account_read", "AdminAccountsCreate": ".admin_accounts_create", "AdminAccountsDelete": ".admin_accounts_delete", "AdminAccountsDeleteTarget": ".admin_accounts_delete_target", "AdminAccountsResponse": ".admin_accounts_response", "AdminApiKeyCreate": ".admin_api_key_create", "AdminApiKeyResponse": ".admin_api_key_response", "AdminDeleteResponse": ".admin_delete_response", "AdminDeletedEntities": ".admin_deleted_entities", "AdminDeletedEntity": ".admin_deleted_entity", "AdminOrganizationCreate": ".admin_organization_create", "AdminOrganizationMembershipCreate": ".admin_organization_membership_create", "AdminOrganizationMembershipRead": ".admin_organization_membership_read", "AdminOrganizationRead": ".admin_organization_read", "AdminProjectCreate": ".admin_project_create", "AdminProjectMembershipCreate": ".admin_project_membership_create", "AdminProjectMembershipRead": ".admin_project_membership_read", "AdminProjectRead": ".admin_project_read", "AdminSimpleAccountCreate": ".admin_simple_account_create", "AdminSimpleAccountDeleteEntry": ".admin_simple_account_delete_entry", "AdminSimpleAccountRead": ".admin_simple_account_read", "AdminSimpleAccountsApiKeysCreate": ".admin_simple_accounts_api_keys_create", "AdminSimpleAccountsCreate": ".admin_simple_accounts_create", "AdminSimpleAccountsDelete": ".admin_simple_accounts_delete", "AdminSimpleAccountsOrganizationsCreate": ".admin_simple_accounts_organizations_create", "AdminSimpleAccountsOrganizationsMembershipsCreate": ".admin_simple_accounts_organizations_memberships_create", "AdminSimpleAccountsOrganizationsTransferOwnership": ".admin_simple_accounts_organizations_transfer_ownership", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjects": ".admin_simple_accounts_organizations_transfer_ownership_include_projects", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjectsZero": ".admin_simple_accounts_organizations_transfer_ownership_include_projects_zero", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspaces": ".admin_simple_accounts_organizations_transfer_ownership_include_workspaces", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspacesZero": ".admin_simple_accounts_organizations_transfer_ownership_include_workspaces_zero", "AdminSimpleAccountsOrganizationsTransferOwnershipResponse": ".admin_simple_accounts_organizations_transfer_ownership_response", "AdminSimpleAccountsProjectsCreate": ".admin_simple_accounts_projects_create", "AdminSimpleAccountsProjectsMembershipsCreate": ".admin_simple_accounts_projects_memberships_create", "AdminSimpleAccountsResponse": ".admin_simple_accounts_response", "AdminSimpleAccountsUsersCreate": ".admin_simple_accounts_users_create", "AdminSimpleAccountsUsersIdentitiesCreate": ".admin_simple_accounts_users_identities_create", "AdminSimpleAccountsUsersResetPassword": ".admin_simple_accounts_users_reset_password", "AdminSimpleAccountsWorkspacesCreate": ".admin_simple_accounts_workspaces_create", "AdminSimpleAccountsWorkspacesMembershipsCreate": ".admin_simple_accounts_workspaces_memberships_create", "AdminStructuredError": ".admin_structured_error", "AdminSubscriptionCreate": ".admin_subscription_create", "AdminSubscriptionRead": ".admin_subscription_read", "AdminUserCreate": ".admin_user_create", "AdminUserIdentityCreate": ".admin_user_identity_create", "AdminUserIdentityRead": ".admin_user_identity_read", "AdminUserIdentityReadStatus": ".admin_user_identity_read_status", "AdminUserRead": ".admin_user_read", "AdminWorkspaceCreate": ".admin_workspace_create", "AdminWorkspaceMembershipCreate": ".admin_workspace_membership_create", "AdminWorkspaceMembershipRead": ".admin_workspace_membership_read", "AdminWorkspaceRead": ".admin_workspace_read", "AgentTemplateOverlay": ".agent_template_overlay", "Analytics": ".analytics", "AnalyticsResponse": ".analytics_response", "Annotation": ".annotation", "AnnotationCreate": ".annotation_create", "AnnotationCreateLinks": ".annotation_create_links", "AnnotationEdit": ".annotation_edit", "AnnotationEditLinks": ".annotation_edit_links", "AnnotationLinkResponse": ".annotation_link_response", "AnnotationLinks": ".annotation_links", "AnnotationQuery": ".annotation_query", "AnnotationQueryLinks": ".annotation_query_links", "AnnotationResponse": ".annotation_response", "AnnotationsResponse": ".annotations_response", "Application": ".application", "ApplicationArtifactFlags": ".application_artifact_flags", "ApplicationArtifactQueryFlags": ".application_artifact_query_flags", "ApplicationCatalogPreset": ".application_catalog_preset", "ApplicationCatalogPresetResponse": ".application_catalog_preset_response", "ApplicationCatalogPresetsResponse": ".application_catalog_presets_response", "ApplicationCatalogTemplate": ".application_catalog_template", "ApplicationCatalogTemplateResponse": ".application_catalog_template_response", "ApplicationCatalogTemplatesResponse": ".application_catalog_templates_response", "ApplicationCatalogType": ".application_catalog_type", "ApplicationCatalogTypesResponse": ".application_catalog_types_response", "ApplicationCreate": ".application_create", "ApplicationEdit": ".application_edit", "ApplicationFlags": ".application_flags", "ApplicationQuery": ".application_query", "ApplicationResponse": ".application_response", "ApplicationRevisionCommit": ".application_revision_commit", "ApplicationRevisionCreate": ".application_revision_create", "ApplicationRevisionDataInput": ".application_revision_data_input", "ApplicationRevisionDataInputHeadersValue": ".application_revision_data_input_headers_value", "ApplicationRevisionDataInputRuntime": ".application_revision_data_input_runtime", "ApplicationRevisionDataOutput": ".application_revision_data_output", "ApplicationRevisionDataOutputHeadersValue": ".application_revision_data_output_headers_value", "ApplicationRevisionDataOutputRuntime": ".application_revision_data_output_runtime", "ApplicationRevisionEdit": ".application_revision_edit", "ApplicationRevisionFlags": ".application_revision_flags", "ApplicationRevisionInput": ".application_revision_input", "ApplicationRevisionOutput": ".application_revision_output", "ApplicationRevisionQuery": ".application_revision_query", "ApplicationRevisionQueryFlags": ".application_revision_query_flags", "ApplicationRevisionResolveResponse": ".application_revision_resolve_response", "ApplicationRevisionResponse": ".application_revision_response", "ApplicationRevisionsLog": ".application_revisions_log", "ApplicationRevisionsResponse": ".application_revisions_response", "ApplicationVariant": ".application_variant", "ApplicationVariantCreate": ".application_variant_create", "ApplicationVariantEdit": ".application_variant_edit", "ApplicationVariantFlags": ".application_variant_flags", "ApplicationVariantFork": ".application_variant_fork", "ApplicationVariantResponse": ".application_variant_response", "ApplicationVariantsResponse": ".application_variants_response", "ApplicationsResponse": ".applications_response", "BodyConfigsFetchVariantsConfigsFetchPost": ".body_configs_fetch_variants_configs_fetch_post", "Bucket": ".bucket", "BuiltinToolConfig": ".builtin_tool_config", "BuiltinToolConfigPermission": ".builtin_tool_config_permission", "CapabilitiesResult": ".capabilities_result", "Capability": ".capability", "CapabilityConnection": ".capability_connection", "CapabilityGuidance": ".capability_guidance", "CollectStatusResponse": ".collect_status_response", "CommandMode": ".command_mode", "ComparisonOperator": ".comparison_operator", "Condition": ".condition", "ConditionOperator": ".condition_operator", "ConditionOptions": ".condition_options", "ConditionValue": ".condition_value", "ConfigResponseModel": ".config_response_model", "ConnectAffordance": ".connect_affordance", "ConnectionRequirement": ".connection_requirement", "CustomModelSettingsDto": ".custom_model_settings_dto", "CustomProviderDto": ".custom_provider_dto", "CustomProviderKind": ".custom_provider_kind", "CustomProviderSettingsDto": ".custom_provider_settings_dto", "CustomSecretDto": ".custom_secret_dto", "CustomSecretFormat": ".custom_secret_format", "CustomSecretSettingsDto": ".custom_secret_settings_dto", "CustomSecretSettingsDtoContent": ".custom_secret_settings_dto_content", "CustomSecretSettingsDtoContentOneValue": ".custom_secret_settings_dto_content_one_value", "DictOperator": ".dict_operator", "DiscoverResponse": ".discover_response", "DiscoverResponseMethodsValue": ".discover_response_methods_value", "DiscoveredAlternative": ".discovered_alternative", "DiscoveredTool": ".discovered_tool", "DiscoveredToolType": ".discovered_tool_type", "DiscoveredTriggerAlternative": ".discovered_trigger_alternative", "DiscoveredTriggerEvent": ".discovered_trigger_event", "DiscoveredTriggerEventType": ".discovered_trigger_event_type", "EntityRef": ".entity_ref", "Environment": ".environment", "EnvironmentCreate": ".environment_create", "EnvironmentEdit": ".environment_edit", "EnvironmentFlags": ".environment_flags", "EnvironmentQueryFlags": ".environment_query_flags", "EnvironmentResponse": ".environment_response", "EnvironmentRevisionCommit": ".environment_revision_commit", "EnvironmentRevisionCreate": ".environment_revision_create", "EnvironmentRevisionData": ".environment_revision_data", "EnvironmentRevisionDelta": ".environment_revision_delta", "EnvironmentRevisionEdit": ".environment_revision_edit", "EnvironmentRevisionInput": ".environment_revision_input", "EnvironmentRevisionOutput": ".environment_revision_output", "EnvironmentRevisionResolveResponse": ".environment_revision_resolve_response", "EnvironmentRevisionResponse": ".environment_revision_response", "EnvironmentRevisionsLog": ".environment_revisions_log", "EnvironmentRevisionsResponse": ".environment_revisions_response", "EnvironmentVariant": ".environment_variant", "EnvironmentVariantCreate": ".environment_variant_create", "EnvironmentVariantEdit": ".environment_variant_edit", "EnvironmentVariantFork": ".environment_variant_fork", "EnvironmentVariantResponse": ".environment_variant_response", "EnvironmentVariantsResponse": ".environment_variants_response", "EnvironmentsResponse": ".environments_response", "ErrorPolicy": ".error_policy", "EvaluationMetrics": ".evaluation_metrics", "EvaluationMetricsCreate": ".evaluation_metrics_create", "EvaluationMetricsIdsResponse": ".evaluation_metrics_ids_response", "EvaluationMetricsQuery": ".evaluation_metrics_query", "EvaluationMetricsQueryScenarioIds": ".evaluation_metrics_query_scenario_ids", "EvaluationMetricsQueryTimestamps": ".evaluation_metrics_query_timestamps", "EvaluationMetricsRefresh": ".evaluation_metrics_refresh", "EvaluationMetricsResponse": ".evaluation_metrics_response", "EvaluationMetricsSetRequest": ".evaluation_metrics_set_request", "EvaluationQueue": ".evaluation_queue", "EvaluationQueueCreate": ".evaluation_queue_create", "EvaluationQueueData": ".evaluation_queue_data", "EvaluationQueueEdit": ".evaluation_queue_edit", "EvaluationQueueFlags": ".evaluation_queue_flags", "EvaluationQueueIdResponse": ".evaluation_queue_id_response", "EvaluationQueueIdsResponse": ".evaluation_queue_ids_response", "EvaluationQueueQuery": ".evaluation_queue_query", "EvaluationQueueQueryFlags": ".evaluation_queue_query_flags", "EvaluationQueueResponse": ".evaluation_queue_response", "EvaluationQueueScenariosQuery": ".evaluation_queue_scenarios_query", "EvaluationQueuesResponse": ".evaluation_queues_response", "EvaluationResult": ".evaluation_result", "EvaluationResultCreate": ".evaluation_result_create", "EvaluationResultIdResponse": ".evaluation_result_id_response", "EvaluationResultIdsResponse": ".evaluation_result_ids_response", "EvaluationResultQuery": ".evaluation_result_query", "EvaluationResultResponse": ".evaluation_result_response", "EvaluationResultsResponse": ".evaluation_results_response", "EvaluationResultsSetRequest": ".evaluation_results_set_request", "EvaluationRun": ".evaluation_run", "EvaluationRunCreate": ".evaluation_run_create", "EvaluationRunDataConcurrency": ".evaluation_run_data_concurrency", "EvaluationRunDataInput": ".evaluation_run_data_input", "EvaluationRunDataMapping": ".evaluation_run_data_mapping", "EvaluationRunDataMappingColumn": ".evaluation_run_data_mapping_column", "EvaluationRunDataMappingStep": ".evaluation_run_data_mapping_step", "EvaluationRunDataOutput": ".evaluation_run_data_output", "EvaluationRunDataStepInput": ".evaluation_run_data_step_input", "EvaluationRunDataStepInputKey": ".evaluation_run_data_step_input_key", "EvaluationRunDataStepInputOrigin": ".evaluation_run_data_step_input_origin", "EvaluationRunDataStepInputType": ".evaluation_run_data_step_input_type", "EvaluationRunDataStepOutput": ".evaluation_run_data_step_output", "EvaluationRunDataStepOutputOrigin": ".evaluation_run_data_step_output_origin", "EvaluationRunDataStepOutputType": ".evaluation_run_data_step_output_type", "EvaluationRunEdit": ".evaluation_run_edit", "EvaluationRunFlags": ".evaluation_run_flags", "EvaluationRunIdResponse": ".evaluation_run_id_response", "EvaluationRunIdsRequest": ".evaluation_run_ids_request", "EvaluationRunIdsResponse": ".evaluation_run_ids_response", "EvaluationRunQuery": ".evaluation_run_query", "EvaluationRunQueryFlags": ".evaluation_run_query_flags", "EvaluationRunResponse": ".evaluation_run_response", "EvaluationRunsResponse": ".evaluation_runs_response", "EvaluationScenario": ".evaluation_scenario", "EvaluationScenarioCreate": ".evaluation_scenario_create", "EvaluationScenarioEdit": ".evaluation_scenario_edit", "EvaluationScenarioIdResponse": ".evaluation_scenario_id_response", "EvaluationScenarioIdsResponse": ".evaluation_scenario_ids_response", "EvaluationScenarioQuery": ".evaluation_scenario_query", "EvaluationScenarioResponse": ".evaluation_scenario_response", "EvaluationScenariosResponse": ".evaluation_scenarios_response", "EvaluationStatus": ".evaluation_status", "Evaluator": ".evaluator", "EvaluatorArtifactFlags": ".evaluator_artifact_flags", "EvaluatorArtifactQueryFlags": ".evaluator_artifact_query_flags", "EvaluatorCatalogPreset": ".evaluator_catalog_preset", "EvaluatorCatalogPresetResponse": ".evaluator_catalog_preset_response", "EvaluatorCatalogPresetsResponse": ".evaluator_catalog_presets_response", "EvaluatorCatalogTemplate": ".evaluator_catalog_template", "EvaluatorCatalogTemplateResponse": ".evaluator_catalog_template_response", "EvaluatorCatalogTemplatesResponse": ".evaluator_catalog_templates_response", "EvaluatorCatalogType": ".evaluator_catalog_type", "EvaluatorCatalogTypesResponse": ".evaluator_catalog_types_response", "EvaluatorCreate": ".evaluator_create", "EvaluatorEdit": ".evaluator_edit", "EvaluatorFlags": ".evaluator_flags", "EvaluatorQuery": ".evaluator_query", "EvaluatorResponse": ".evaluator_response", "EvaluatorRevisionCommit": ".evaluator_revision_commit", "EvaluatorRevisionCreate": ".evaluator_revision_create", "EvaluatorRevisionDataInput": ".evaluator_revision_data_input", "EvaluatorRevisionDataInputHeadersValue": ".evaluator_revision_data_input_headers_value", "EvaluatorRevisionDataInputRuntime": ".evaluator_revision_data_input_runtime", "EvaluatorRevisionDataOutput": ".evaluator_revision_data_output", "EvaluatorRevisionDataOutputHeadersValue": ".evaluator_revision_data_output_headers_value", "EvaluatorRevisionDataOutputRuntime": ".evaluator_revision_data_output_runtime", "EvaluatorRevisionEdit": ".evaluator_revision_edit", "EvaluatorRevisionFlags": ".evaluator_revision_flags", "EvaluatorRevisionInput": ".evaluator_revision_input", "EvaluatorRevisionOutput": ".evaluator_revision_output", "EvaluatorRevisionQuery": ".evaluator_revision_query", "EvaluatorRevisionQueryFlags": ".evaluator_revision_query_flags", "EvaluatorRevisionResolveResponse": ".evaluator_revision_resolve_response", "EvaluatorRevisionResponse": ".evaluator_revision_response", "EvaluatorRevisionsLog": ".evaluator_revisions_log", "EvaluatorRevisionsResponse": ".evaluator_revisions_response", "EvaluatorTemplate": ".evaluator_template", "EvaluatorTemplatesResponse": ".evaluator_templates_response", "EvaluatorVariant": ".evaluator_variant", "EvaluatorVariantCreate": ".evaluator_variant_create", "EvaluatorVariantEdit": ".evaluator_variant_edit", "EvaluatorVariantFlags": ".evaluator_variant_flags", "EvaluatorVariantFork": ".evaluator_variant_fork", "EvaluatorVariantResponse": ".evaluator_variant_response", "EvaluatorVariantsResponse": ".evaluator_variants_response", "EvaluatorsResponse": ".evaluators_response", "Event": ".event", "EventQuery": ".event_query", "EventType": ".event_type", "EventsQueryResponse": ".events_query_response", "ExistenceOperator": ".existence_operator", "FilteringInput": ".filtering_input", "FilteringInputConditionsItem": ".filtering_input_conditions_item", "FilteringOutput": ".filtering_output", "FilteringOutputConditionsItem": ".filtering_output_conditions_item", "Focus": ".focus", "Folder": ".folder", "FolderCreate": ".folder_create", "FolderEdit": ".folder_edit", "FolderIdResponse": ".folder_id_response", "FolderKind": ".folder_kind", "FolderQuery": ".folder_query", "FolderQueryKinds": ".folder_query_kinds", "FolderResponse": ".folder_response", "FoldersResponse": ".folders_response", "Format": ".format", "Formatting": ".formatting", typing.Any: ".full_json_input", typing.Any: ".full_json_output", "GatewayToolConfig": ".gateway_tool_config", "GatewayToolConfigPermission": ".gateway_tool_config_permission", "HarnessKind": ".harness_kind", "Header": ".header", "HttpValidationError": ".http_validation_error", "InviteRequest": ".invite_request", "Invocation": ".invocation", "InvocationCreate": ".invocation_create", "InvocationCreateLinks": ".invocation_create_links", "InvocationEdit": ".invocation_edit", "InvocationEditLinks": ".invocation_edit_links", "InvocationLinkResponse": ".invocation_link_response", "InvocationLinks": ".invocation_links", "InvocationQuery": ".invocation_query", "InvocationQueryLinks": ".invocation_query_links", "InvocationResponse": ".invocation_response", "InvocationsResponse": ".invocations_response", "JsonSchemasInput": ".json_schemas_input", "JsonSchemasOutput": ".json_schemas_output", typing.Any: ".label_json_input", typing.Any: ".label_json_output", "LegacyLifecycleDto": ".legacy_lifecycle_dto", "ListApiKeysResponse": ".list_api_keys_response", "ListOperator": ".list_operator", "ListOptions": ".list_options", "LogicalOperator": ".logical_operator", "MetricSpec": ".metric_spec", "MetricType": ".metric_type", "MetricsBucket": ".metrics_bucket", "Mount": ".mount", "MountCreate": ".mount_create", "MountCredentials": ".mount_credentials", "MountCredentialsResponse": ".mount_credentials_response", "MountData": ".mount_data", "MountEdit": ".mount_edit", "MountFileDeletedResponse": ".mount_file_deleted_response", "MountFileWrittenResponse": ".mount_file_written_response", "MountFlags": ".mount_flags", "MountFolderCreatedResponse": ".mount_folder_created_response", "MountQuery": ".mount_query", "MountResponse": ".mount_response", "MountsResponse": ".mounts_response", "NumericOperator": ".numeric_operator", "OTelEventInput": ".o_tel_event_input", "OTelEventInputTimestamp": ".o_tel_event_input_timestamp", "OTelEventOutput": ".o_tel_event_output", "OTelEventOutputTimestamp": ".o_tel_event_output_timestamp", "OTelHashInput": ".o_tel_hash_input", "OTelHashOutput": ".o_tel_hash_output", "OTelLinkInput": ".o_tel_link_input", "OTelLinkOutput": ".o_tel_link_output", "OTelLinksResponse": ".o_tel_links_response", "OTelReferenceInput": ".o_tel_reference_input", "OTelReferenceOutput": ".o_tel_reference_output", "OTelSpanKind": ".o_tel_span_kind", "OTelStatusCode": ".o_tel_status_code", "OTelTracingRequest": ".o_tel_tracing_request", "OTelTracingResponse": ".o_tel_tracing_response", "OldAnalyticsResponse": ".old_analytics_response", "Organization": ".organization", "OrganizationDetails": ".organization_details", "OrganizationDomainResponse": ".organization_domain_response", "OrganizationProviderResponse": ".organization_provider_response", "OrganizationUpdate": ".organization_update", "Permission": ".permission", "PlaygroundBuildKitContext": ".playground_build_kit_context", "ProjectsResponse": ".projects_response", "QueriesResponse": ".queries_response", "Query": ".query", "QueryCreate": ".query_create", "QueryEdit": ".query_edit", "QueryFlags": ".query_flags", "QueryQueryFlags": ".query_query_flags", "QueryResponse": ".query_response", "QueryRevision": ".query_revision", "QueryRevisionCommit": ".query_revision_commit", "QueryRevisionCreate": ".query_revision_create", "QueryRevisionDataInput": ".query_revision_data_input", "QueryRevisionDataOutput": ".query_revision_data_output", "QueryRevisionEdit": ".query_revision_edit", "QueryRevisionQuery": ".query_revision_query", "QueryRevisionResponse": ".query_revision_response", "QueryRevisionsLog": ".query_revisions_log", "QueryRevisionsResponse": ".query_revisions_response", "QueryVariant": ".query_variant", "QueryVariantCreate": ".query_variant_create", "QueryVariantEdit": ".query_variant_edit", "QueryVariantFork": ".query_variant_fork", "QueryVariantQuery": ".query_variant_query", "QueryVariantResponse": ".query_variant_response", "QueryVariantsResponse": ".query_variants_response", "Reference": ".reference", "ReferenceRequestModelInput": ".reference_request_model_input", "ReferenceRequestModelOutput": ".reference_request_model_output", "RequestType": ".request_type", "ResolutionInfo": ".resolution_info", "ResolvedTool": ".resolved_tool", "RetrievalInfo": ".retrieval_info", "SecretDto": ".secret_dto", "SecretDtoData": ".secret_dto_data", "SecretKind": ".secret_kind", "SecretResponseDto": ".secret_response_dto", "SecretResponseDtoData": ".secret_response_dto_data", "Selector": ".selector", "SessionHeartbeatResult": ".session_heartbeat_result", "SessionIdsResponse": ".session_ids_response", "SessionInteraction": ".session_interaction", "SessionInteractionData": ".session_interaction_data", "SessionInteractionFlags": ".session_interaction_flags", "SessionInteractionKind": ".session_interaction_kind", "SessionInteractionQuery": ".session_interaction_query", "SessionInteractionQueryFlags": ".session_interaction_query_flags", "SessionInteractionResponse": ".session_interaction_response", "SessionInteractionStatus": ".session_interaction_status", "SessionInteractionsResponse": ".session_interactions_response", "SessionMount": ".session_mount", "SessionMountQuery": ".session_mount_query", "SessionMountsResponse": ".session_mounts_response", "SessionRecord": ".session_record", "SessionRecordResponse": ".session_record_response", "SessionRecordsQueryResponse": ".session_records_query_response", "SessionResponse": ".session_response", "SessionStream": ".session_stream", "SessionStreamCommandResponse": ".session_stream_command_response", "SessionStreamFlags": ".session_stream_flags", "SessionStreamHeaderEdit": ".session_stream_header_edit", "SessionStreamResponse": ".session_stream_response", "SessionStreamsResponse": ".session_streams_response", "SessionTurn": ".session_turn", "SessionTurnQuery": ".session_turn_query", "SessionTurnResponse": ".session_turn_response", "SessionTurnsResponse": ".session_turns_response", "SessionsResponse": ".sessions_response", "SimpleApplication": ".simple_application", "SimpleApplicationAdditionalContext": ".simple_application_additional_context", "SimpleApplicationCreate": ".simple_application_create", "SimpleApplicationDataInput": ".simple_application_data_input", "SimpleApplicationDataInputHeadersValue": ".simple_application_data_input_headers_value", "SimpleApplicationDataInputRuntime": ".simple_application_data_input_runtime", "SimpleApplicationDataOutput": ".simple_application_data_output", "SimpleApplicationDataOutputHeadersValue": ".simple_application_data_output_headers_value", "SimpleApplicationDataOutputRuntime": ".simple_application_data_output_runtime", "SimpleApplicationEdit": ".simple_application_edit", "SimpleApplicationFlags": ".simple_application_flags", "SimpleApplicationQuery": ".simple_application_query", "SimpleApplicationQueryFlags": ".simple_application_query_flags", "SimpleApplicationResponse": ".simple_application_response", "SimpleApplicationsResponse": ".simple_applications_response", "SimpleEnvironment": ".simple_environment", "SimpleEnvironmentCreate": ".simple_environment_create", "SimpleEnvironmentEdit": ".simple_environment_edit", "SimpleEnvironmentQuery": ".simple_environment_query", "SimpleEnvironmentResponse": ".simple_environment_response", "SimpleEnvironmentsResponse": ".simple_environments_response", "SimpleEvaluation": ".simple_evaluation", "SimpleEvaluationCreate": ".simple_evaluation_create", "SimpleEvaluationData": ".simple_evaluation_data", "SimpleEvaluationDataApplicationSteps": ".simple_evaluation_data_application_steps", "SimpleEvaluationDataApplicationStepsOneValue": ".simple_evaluation_data_application_steps_one_value", "SimpleEvaluationDataEvaluatorSteps": ".simple_evaluation_data_evaluator_steps", "SimpleEvaluationDataEvaluatorStepsOneValue": ".simple_evaluation_data_evaluator_steps_one_value", "SimpleEvaluationDataQuerySteps": ".simple_evaluation_data_query_steps", "SimpleEvaluationDataQueryStepsOneValue": ".simple_evaluation_data_query_steps_one_value", "SimpleEvaluationDataTestsetSteps": ".simple_evaluation_data_testset_steps", "SimpleEvaluationDataTestsetStepsOneValue": ".simple_evaluation_data_testset_steps_one_value", "SimpleEvaluationEdit": ".simple_evaluation_edit", "SimpleEvaluationIdResponse": ".simple_evaluation_id_response", "SimpleEvaluationQuery": ".simple_evaluation_query", "SimpleEvaluationResponse": ".simple_evaluation_response", "SimpleEvaluationsResponse": ".simple_evaluations_response", "SimpleEvaluator": ".simple_evaluator", "SimpleEvaluatorCreate": ".simple_evaluator_create", "SimpleEvaluatorDataInput": ".simple_evaluator_data_input", "SimpleEvaluatorDataInputHeadersValue": ".simple_evaluator_data_input_headers_value", "SimpleEvaluatorDataInputRuntime": ".simple_evaluator_data_input_runtime", "SimpleEvaluatorDataOutput": ".simple_evaluator_data_output", "SimpleEvaluatorDataOutputHeadersValue": ".simple_evaluator_data_output_headers_value", "SimpleEvaluatorDataOutputRuntime": ".simple_evaluator_data_output_runtime", "SimpleEvaluatorEdit": ".simple_evaluator_edit", "SimpleEvaluatorFlags": ".simple_evaluator_flags", "SimpleEvaluatorQuery": ".simple_evaluator_query", "SimpleEvaluatorQueryFlags": ".simple_evaluator_query_flags", "SimpleEvaluatorResponse": ".simple_evaluator_response", "SimpleEvaluatorsResponse": ".simple_evaluators_response", "SimpleQueriesResponse": ".simple_queries_response", "SimpleQuery": ".simple_query", "SimpleQueryCreate": ".simple_query_create", "SimpleQueryEdit": ".simple_query_edit", "SimpleQueryQuery": ".simple_query_query", "SimpleQueryResponse": ".simple_query_response", "SimpleQueue": ".simple_queue", "SimpleQueueCreate": ".simple_queue_create", "SimpleQueueData": ".simple_queue_data", "SimpleQueueDataEvaluators": ".simple_queue_data_evaluators", "SimpleQueueDataEvaluatorsOneValue": ".simple_queue_data_evaluators_one_value", "SimpleQueueIdResponse": ".simple_queue_id_response", "SimpleQueueIdsResponse": ".simple_queue_ids_response", "SimpleQueueKind": ".simple_queue_kind", "SimpleQueueQuery": ".simple_queue_query", "SimpleQueueResponse": ".simple_queue_response", "SimpleQueueScenariosQuery": ".simple_queue_scenarios_query", "SimpleQueueScenariosResponse": ".simple_queue_scenarios_response", "SimpleQueueSettings": ".simple_queue_settings", "SimpleQueuesResponse": ".simple_queues_response", "SimpleTestset": ".simple_testset", "SimpleTestsetCreate": ".simple_testset_create", "SimpleTestsetEdit": ".simple_testset_edit", "SimpleTestsetQuery": ".simple_testset_query", "SimpleTestsetResponse": ".simple_testset_response", "SimpleTestsetsResponse": ".simple_testsets_response", "SimpleTrace": ".simple_trace", "SimpleTraceChannel": ".simple_trace_channel", "SimpleTraceCreate": ".simple_trace_create", "SimpleTraceCreateLinks": ".simple_trace_create_links", "SimpleTraceEdit": ".simple_trace_edit", "SimpleTraceEditLinks": ".simple_trace_edit_links", "SimpleTraceKind": ".simple_trace_kind", "SimpleTraceLinkResponse": ".simple_trace_link_response", "SimpleTraceLinks": ".simple_trace_links", "SimpleTraceOrigin": ".simple_trace_origin", "SimpleTraceQuery": ".simple_trace_query", "SimpleTraceQueryLinks": ".simple_trace_query_links", "SimpleTraceReferences": ".simple_trace_references", "SimpleTraceResponse": ".simple_trace_response", "SimpleTracesResponse": ".simple_traces_response", "SimpleWorkflow": ".simple_workflow", "SimpleWorkflowCreate": ".simple_workflow_create", "SimpleWorkflowDataInput": ".simple_workflow_data_input", "SimpleWorkflowDataInputHeadersValue": ".simple_workflow_data_input_headers_value", "SimpleWorkflowDataInputRuntime": ".simple_workflow_data_input_runtime", "SimpleWorkflowDataOutput": ".simple_workflow_data_output", "SimpleWorkflowDataOutputHeadersValue": ".simple_workflow_data_output_headers_value", "SimpleWorkflowDataOutputRuntime": ".simple_workflow_data_output_runtime", "SimpleWorkflowEdit": ".simple_workflow_edit", "SimpleWorkflowFlags": ".simple_workflow_flags", "SimpleWorkflowQuery": ".simple_workflow_query", "SimpleWorkflowQueryFlags": ".simple_workflow_query_flags", "SimpleWorkflowResponse": ".simple_workflow_response", "SimpleWorkflowsResponse": ".simple_workflows_response", "SpanInput": ".span_input", "SpanInputEndTime": ".span_input_end_time", "SpanInputStartTime": ".span_input_start_time", "SpanOutput": ".span_output", "SpanOutputEndTime": ".span_output_end_time", "SpanOutputStartTime": ".span_output_start_time", "SpanResponse": ".span_response", "SpanType": ".span_type", "SpansNodeInput": ".spans_node_input", "SpansNodeInputEndTime": ".spans_node_input_end_time", "SpansNodeInputSpansValue": ".spans_node_input_spans_value", "SpansNodeInputStartTime": ".spans_node_input_start_time", "SpansNodeOutput": ".spans_node_output", "SpansNodeOutputEndTime": ".spans_node_output_end_time", "SpansNodeOutputSpansValue": ".spans_node_output_spans_value", "SpansNodeOutputStartTime": ".spans_node_output_start_time", "SpansResponse": ".spans_response", "SpansTreeInput": ".spans_tree_input", "SpansTreeInputSpansValue": ".spans_tree_input_spans_value", "SpansTreeOutput": ".spans_tree_output", "SpansTreeOutputSpansValue": ".spans_tree_output_spans_value", "SsoProviderDto": ".sso_provider_dto", "SsoProviderInfo": ".sso_provider_info", "SsoProviderSettingsDto": ".sso_provider_settings_dto", "SsoProviders": ".sso_providers", "StandardProviderDto": ".standard_provider_dto", "StandardProviderKind": ".standard_provider_kind", "StandardProviderSettingsDto": ".standard_provider_settings_dto", "Status": ".status", "StringOperator": ".string_operator", "TestcaseInput": ".testcase_input", "TestcaseOutput": ".testcase_output", "TestcaseResponse": ".testcase_response", "TestcasesResponse": ".testcases_response", "Testset": ".testset", "TestsetCreate": ".testset_create", "TestsetEdit": ".testset_edit", "TestsetFlags": ".testset_flags", "TestsetQuery": ".testset_query", "TestsetResponse": ".testset_response", "TestsetRevision": ".testset_revision", "TestsetRevisionCommit": ".testset_revision_commit", "TestsetRevisionCreate": ".testset_revision_create", "TestsetRevisionDataInput": ".testset_revision_data_input", "TestsetRevisionDataOutput": ".testset_revision_data_output", "TestsetRevisionDelta": ".testset_revision_delta", "TestsetRevisionDeltaColumns": ".testset_revision_delta_columns", "TestsetRevisionDeltaRows": ".testset_revision_delta_rows", "TestsetRevisionEdit": ".testset_revision_edit", "TestsetRevisionQuery": ".testset_revision_query", "TestsetRevisionResponse": ".testset_revision_response", "TestsetRevisionsLog": ".testset_revisions_log", "TestsetRevisionsResponse": ".testset_revisions_response", "TestsetVariant": ".testset_variant", "TestsetVariantCreate": ".testset_variant_create", "TestsetVariantEdit": ".testset_variant_edit", "TestsetVariantFork": ".testset_variant_fork", "TestsetVariantQuery": ".testset_variant_query", "TestsetVariantResponse": ".testset_variant_response", "TestsetVariantsResponse": ".testset_variants_response", "TestsetsResponse": ".testsets_response", "TextOptions": ".text_options", "ToolAuthScheme": ".tool_auth_scheme", "ToolCallData": ".tool_call_data", "ToolCallFunction": ".tool_call_function", "ToolCallResponse": ".tool_call_response", "ToolCatalogAction": ".tool_catalog_action", "ToolCatalogActionDetails": ".tool_catalog_action_details", "ToolCatalogActionResponse": ".tool_catalog_action_response", "ToolCatalogActionResponseAction": ".tool_catalog_action_response_action", "ToolCatalogActionsResponse": ".tool_catalog_actions_response", "ToolCatalogActionsResponseActionsItem": ".tool_catalog_actions_response_actions_item", "ToolCatalogCategoriesResponse": ".tool_catalog_categories_response", "ToolCatalogCategory": ".tool_catalog_category", "ToolCatalogIntegration": ".tool_catalog_integration", "ToolCatalogIntegrationDetails": ".tool_catalog_integration_details", "ToolCatalogIntegrationResponse": ".tool_catalog_integration_response", "ToolCatalogIntegrationResponseIntegration": ".tool_catalog_integration_response_integration", "ToolCatalogIntegrationsResponse": ".tool_catalog_integrations_response", "ToolCatalogIntegrationsResponseIntegrationsItem": ".tool_catalog_integrations_response_integrations_item", "ToolCatalogProvider": ".tool_catalog_provider", "ToolCatalogProviderDetails": ".tool_catalog_provider_details", "ToolCatalogProviderResponse": ".tool_catalog_provider_response", "ToolCatalogProviderResponseProvider": ".tool_catalog_provider_response_provider", "ToolCatalogProvidersResponse": ".tool_catalog_providers_response", "ToolCatalogProvidersResponseProvidersItem": ".tool_catalog_providers_response_providers_item", "ToolConnection": ".tool_connection", "ToolConnectionCreate": ".tool_connection_create", "ToolConnectionCreateData": ".tool_connection_create_data", "ToolConnectionResponse": ".tool_connection_response", "ToolConnectionState": ".tool_connection_state", "ToolConnectionStatus": ".tool_connection_status", "ToolConnectionsResponse": ".tool_connections_response", "ToolProviderKind": ".tool_provider_kind", "ToolResolveResponse": ".tool_resolve_response", "ToolResult": ".tool_result", "ToolResultData": ".tool_result_data", "TraceIdResponse": ".trace_id_response", "TraceIdsResponse": ".trace_ids_response", "TraceInput": ".trace_input", "TraceInputSpansValue": ".trace_input_spans_value", "TraceOutput": ".trace_output", "TraceOutputSpansValue": ".trace_output_spans_value", "TraceRequest": ".trace_request", "TraceResponse": ".trace_response", "TraceType": ".trace_type", "TracesRequest": ".traces_request", "TracesResponse": ".traces_response", "TracingQuery": ".tracing_query", "TriggerAuthScheme": ".trigger_auth_scheme", "TriggerCapabilitiesResult": ".trigger_capabilities_result", "TriggerCapability": ".trigger_capability", "TriggerCapabilityConnection": ".trigger_capability_connection", "TriggerCatalogEvent": ".trigger_catalog_event", "TriggerCatalogEventDetails": ".trigger_catalog_event_details", "TriggerCatalogEventResponse": ".trigger_catalog_event_response", "TriggerCatalogEventsResponse": ".trigger_catalog_events_response", "TriggerCatalogIntegration": ".trigger_catalog_integration", "TriggerCatalogIntegrationResponse": ".trigger_catalog_integration_response", "TriggerCatalogIntegrationsResponse": ".trigger_catalog_integrations_response", "TriggerCatalogProvider": ".trigger_catalog_provider", "TriggerCatalogProviderResponse": ".trigger_catalog_provider_response", "TriggerCatalogProvidersResponse": ".trigger_catalog_providers_response", "TriggerConnectAffordance": ".trigger_connect_affordance", "TriggerConnection": ".trigger_connection", "TriggerConnectionCreate": ".trigger_connection_create", "TriggerConnectionCreateData": ".trigger_connection_create_data", "TriggerConnectionRequirement": ".trigger_connection_requirement", "TriggerConnectionResponse": ".trigger_connection_response", "TriggerConnectionStatus": ".trigger_connection_status", "TriggerConnectionsResponse": ".trigger_connections_response", "TriggerDeliveriesResponse": ".trigger_deliveries_response", "TriggerDelivery": ".trigger_delivery", "TriggerDeliveryData": ".trigger_delivery_data", "TriggerDeliveryQuery": ".trigger_delivery_query", "TriggerDeliveryResponse": ".trigger_delivery_response", "TriggerDiscoveryConnectionState": ".trigger_discovery_connection_state", "TriggerDiscoveryGuidance": ".trigger_discovery_guidance", "TriggerEventAck": ".trigger_event_ack", "TriggerProviderKind": ".trigger_provider_kind", "TriggerSchedule": ".trigger_schedule", "TriggerScheduleCreate": ".trigger_schedule_create", "TriggerScheduleData": ".trigger_schedule_data", "TriggerScheduleDataInputsFields": ".trigger_schedule_data_inputs_fields", "TriggerScheduleEdit": ".trigger_schedule_edit", "TriggerScheduleFlags": ".trigger_schedule_flags", "TriggerScheduleQuery": ".trigger_schedule_query", "TriggerScheduleResponse": ".trigger_schedule_response", "TriggerSchedulesResponse": ".trigger_schedules_response", "TriggerSubscription": ".trigger_subscription", "TriggerSubscriptionCreate": ".trigger_subscription_create", "TriggerSubscriptionCreateRequest": ".trigger_subscription_create_request", "TriggerSubscriptionData": ".trigger_subscription_data", "TriggerSubscriptionDataInputsFields": ".trigger_subscription_data_inputs_fields", "TriggerSubscriptionEdit": ".trigger_subscription_edit", "TriggerSubscriptionFlags": ".trigger_subscription_flags", "TriggerSubscriptionQuery": ".trigger_subscription_query", "TriggerSubscriptionResponse": ".trigger_subscription_response", "TriggerSubscriptionsResponse": ".trigger_subscriptions_response", "UserIdsResponse": ".user_ids_response", "ValidationError": ".validation_error", "ValidationErrorLocItem": ".validation_error_loc_item", "WebhookDeliveriesResponse": ".webhook_deliveries_response", "WebhookDelivery": ".webhook_delivery", "WebhookDeliveryCreate": ".webhook_delivery_create", "WebhookDeliveryData": ".webhook_delivery_data", "WebhookDeliveryQuery": ".webhook_delivery_query", "WebhookDeliveryResponse": ".webhook_delivery_response", "WebhookDeliveryResponseInfo": ".webhook_delivery_response_info", "WebhookEventType": ".webhook_event_type", "WebhookProviderDto": ".webhook_provider_dto", "WebhookProviderSettingsDto": ".webhook_provider_settings_dto", "WebhookSubscription": ".webhook_subscription", "WebhookSubscriptionCreate": ".webhook_subscription_create", "WebhookSubscriptionData": ".webhook_subscription_data", "WebhookSubscriptionDataAuthMode": ".webhook_subscription_data_auth_mode", "WebhookSubscriptionEdit": ".webhook_subscription_edit", "WebhookSubscriptionFlags": ".webhook_subscription_flags", "WebhookSubscriptionQuery": ".webhook_subscription_query", "WebhookSubscriptionResponse": ".webhook_subscription_response", "WebhookSubscriptionsResponse": ".webhook_subscriptions_response", "Windowing": ".windowing", "WindowingOrder": ".windowing_order", "Workflow": ".workflow", "WorkflowArtifactFlags": ".workflow_artifact_flags", "WorkflowCatalogFlags": ".workflow_catalog_flags", "WorkflowCatalogHarness": ".workflow_catalog_harness", "WorkflowCatalogHarnessResponse": ".workflow_catalog_harness_response", "WorkflowCatalogHarnessesResponse": ".workflow_catalog_harnesses_response", "WorkflowCatalogPreset": ".workflow_catalog_preset", "WorkflowCatalogPresetResponse": ".workflow_catalog_preset_response", "WorkflowCatalogPresetsResponse": ".workflow_catalog_presets_response", "WorkflowCatalogTemplate": ".workflow_catalog_template", "WorkflowCatalogTemplateResponse": ".workflow_catalog_template_response", "WorkflowCatalogTemplatesResponse": ".workflow_catalog_templates_response", "WorkflowCatalogType": ".workflow_catalog_type", "WorkflowCatalogTypeResponse": ".workflow_catalog_type_response", "WorkflowCatalogTypesResponse": ".workflow_catalog_types_response", "WorkflowCreate": ".workflow_create", "WorkflowEdit": ".workflow_edit", "WorkflowFlags": ".workflow_flags", "WorkflowRequestData": ".workflow_request_data", "WorkflowResponse": ".workflow_response", "WorkflowRevisionCommit": ".workflow_revision_commit", "WorkflowRevisionCreate": ".workflow_revision_create", "WorkflowRevisionDataInput": ".workflow_revision_data_input", "WorkflowRevisionDataInputHeadersValue": ".workflow_revision_data_input_headers_value", "WorkflowRevisionDataInputRuntime": ".workflow_revision_data_input_runtime", "WorkflowRevisionDataOutput": ".workflow_revision_data_output", "WorkflowRevisionDataOutputHeadersValue": ".workflow_revision_data_output_headers_value", "WorkflowRevisionDataOutputRuntime": ".workflow_revision_data_output_runtime", "WorkflowRevisionDelta": ".workflow_revision_delta", "WorkflowRevisionEdit": ".workflow_revision_edit", "WorkflowRevisionFlags": ".workflow_revision_flags", "WorkflowRevisionInput": ".workflow_revision_input", "WorkflowRevisionOutput": ".workflow_revision_output", "WorkflowRevisionResolveResponse": ".workflow_revision_resolve_response", "WorkflowRevisionResponse": ".workflow_revision_response", "WorkflowRevisionsLog": ".workflow_revisions_log", "WorkflowRevisionsResponse": ".workflow_revisions_response", "WorkflowVariant": ".workflow_variant", "WorkflowVariantCreate": ".workflow_variant_create", "WorkflowVariantEdit": ".workflow_variant_edit", "WorkflowVariantFlags": ".workflow_variant_flags", "WorkflowVariantFork": ".workflow_variant_fork", "WorkflowVariantResponse": ".workflow_variant_response", "WorkflowVariantsResponse": ".workflow_variants_response", "WorkflowsResponse": ".workflows_response", "Workspace": ".workspace", "WorkspaceMemberResponse": ".workspace_member_response", "WorkspacePermission": ".workspace_permission", "WorkspaceResponse": ".workspace_response"} def __getattr__(attr_name: str) -> typing.Any: module_name = _dynamic_imports.get(attr_name) if module_name is None: @@ -791,4 +794,4 @@ def __getattr__(attr_name: str) -> typing.Any: def __dir__(): lazy_attrs = list(_dynamic_imports.keys()) return sorted(lazy_attrs) -__all__ = ["AdminAccountCreateOptions", "AdminAccountRead", "AdminAccountsCreate", "AdminAccountsDelete", "AdminAccountsDeleteTarget", "AdminAccountsResponse", "AdminApiKeyCreate", "AdminApiKeyResponse", "AdminDeleteResponse", "AdminDeletedEntities", "AdminDeletedEntity", "AdminOrganizationCreate", "AdminOrganizationMembershipCreate", "AdminOrganizationMembershipRead", "AdminOrganizationRead", "AdminProjectCreate", "AdminProjectMembershipCreate", "AdminProjectMembershipRead", "AdminProjectRead", "AdminSimpleAccountCreate", "AdminSimpleAccountDeleteEntry", "AdminSimpleAccountRead", "AdminSimpleAccountsApiKeysCreate", "AdminSimpleAccountsCreate", "AdminSimpleAccountsDelete", "AdminSimpleAccountsOrganizationsCreate", "AdminSimpleAccountsOrganizationsMembershipsCreate", "AdminSimpleAccountsOrganizationsTransferOwnership", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjects", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjectsZero", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspaces", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspacesZero", "AdminSimpleAccountsOrganizationsTransferOwnershipResponse", "AdminSimpleAccountsProjectsCreate", "AdminSimpleAccountsProjectsMembershipsCreate", "AdminSimpleAccountsResponse", "AdminSimpleAccountsUsersCreate", "AdminSimpleAccountsUsersIdentitiesCreate", "AdminSimpleAccountsUsersResetPassword", "AdminSimpleAccountsWorkspacesCreate", "AdminSimpleAccountsWorkspacesMembershipsCreate", "AdminStructuredError", "AdminSubscriptionCreate", "AdminSubscriptionRead", "AdminUserCreate", "AdminUserIdentityCreate", "AdminUserIdentityRead", "AdminUserIdentityReadStatus", "AdminUserRead", "AdminWorkspaceCreate", "AdminWorkspaceMembershipCreate", "AdminWorkspaceMembershipRead", "AdminWorkspaceRead", "AgentTemplateOverlay", "Analytics", "AnalyticsResponse", "Annotation", "AnnotationCreate", "AnnotationCreateLinks", "AnnotationEdit", "AnnotationEditLinks", "AnnotationLinkResponse", "AnnotationLinks", "AnnotationQuery", "AnnotationQueryLinks", "AnnotationResponse", "AnnotationsResponse", "Application", "ApplicationArtifactFlags", "ApplicationArtifactQueryFlags", "ApplicationCatalogPreset", "ApplicationCatalogPresetResponse", "ApplicationCatalogPresetsResponse", "ApplicationCatalogTemplate", "ApplicationCatalogTemplateResponse", "ApplicationCatalogTemplatesResponse", "ApplicationCatalogType", "ApplicationCatalogTypesResponse", "ApplicationCreate", "ApplicationEdit", "ApplicationFlags", "ApplicationQuery", "ApplicationResponse", "ApplicationRevisionCommit", "ApplicationRevisionCreate", "ApplicationRevisionDataInput", "ApplicationRevisionDataInputHeadersValue", "ApplicationRevisionDataInputRuntime", "ApplicationRevisionDataOutput", "ApplicationRevisionDataOutputHeadersValue", "ApplicationRevisionDataOutputRuntime", "ApplicationRevisionEdit", "ApplicationRevisionFlags", "ApplicationRevisionInput", "ApplicationRevisionOutput", "ApplicationRevisionQuery", "ApplicationRevisionQueryFlags", "ApplicationRevisionResolveResponse", "ApplicationRevisionResponse", "ApplicationRevisionsLog", "ApplicationRevisionsResponse", "ApplicationVariant", "ApplicationVariantCreate", "ApplicationVariantEdit", "ApplicationVariantFlags", "ApplicationVariantFork", "ApplicationVariantResponse", "ApplicationVariantsResponse", "ApplicationsResponse", "BodyConfigsFetchVariantsConfigsFetchPost", "Bucket", "BuiltinToolConfig", "BuiltinToolConfigPermission", "CapabilitiesResult", "Capability", "CapabilityConnection", "CapabilityGuidance", "CollectStatusResponse", "CommandMode", "ComparisonOperator", "Condition", "ConditionOperator", "ConditionOptions", "ConditionValue", "ConfigResponseModel", "ConnectAffordance", "ConnectionRequirement", "CustomModelSettingsDto", "CustomProviderDto", "CustomProviderKind", "CustomProviderSettingsDto", "CustomSecretDto", "CustomSecretFormat", "CustomSecretSettingsDto", "CustomSecretSettingsDtoContent", "CustomSecretSettingsDtoContentOneValue", "DictOperator", "DiscoverResponse", "DiscoverResponseMethodsValue", "DiscoveredAlternative", "DiscoveredTool", "DiscoveredToolType", "DiscoveredTriggerAlternative", "DiscoveredTriggerEvent", "DiscoveredTriggerEventType", "EntityRef", "Environment", "EnvironmentCreate", "EnvironmentEdit", "EnvironmentFlags", "EnvironmentQueryFlags", "EnvironmentResponse", "EnvironmentRevisionCommit", "EnvironmentRevisionCreate", "EnvironmentRevisionData", "EnvironmentRevisionDelta", "EnvironmentRevisionEdit", "EnvironmentRevisionInput", "EnvironmentRevisionOutput", "EnvironmentRevisionResolveResponse", "EnvironmentRevisionResponse", "EnvironmentRevisionsLog", "EnvironmentRevisionsResponse", "EnvironmentVariant", "EnvironmentVariantCreate", "EnvironmentVariantEdit", "EnvironmentVariantFork", "EnvironmentVariantResponse", "EnvironmentVariantsResponse", "EnvironmentsResponse", "ErrorPolicy", "EvaluationMetrics", "EvaluationMetricsCreate", "EvaluationMetricsIdsResponse", "EvaluationMetricsQuery", "EvaluationMetricsQueryScenarioIds", "EvaluationMetricsQueryTimestamps", "EvaluationMetricsRefresh", "EvaluationMetricsResponse", "EvaluationMetricsSetRequest", "EvaluationQueue", "EvaluationQueueCreate", "EvaluationQueueData", "EvaluationQueueEdit", "EvaluationQueueFlags", "EvaluationQueueIdResponse", "EvaluationQueueIdsResponse", "EvaluationQueueQuery", "EvaluationQueueQueryFlags", "EvaluationQueueResponse", "EvaluationQueueScenariosQuery", "EvaluationQueuesResponse", "EvaluationResult", "EvaluationResultCreate", "EvaluationResultIdResponse", "EvaluationResultIdsResponse", "EvaluationResultQuery", "EvaluationResultResponse", "EvaluationResultsResponse", "EvaluationResultsSetRequest", "EvaluationRun", "EvaluationRunCreate", "EvaluationRunDataConcurrency", "EvaluationRunDataInput", "EvaluationRunDataMapping", "EvaluationRunDataMappingColumn", "EvaluationRunDataMappingStep", "EvaluationRunDataOutput", "EvaluationRunDataStepInput", "EvaluationRunDataStepInputKey", "EvaluationRunDataStepInputOrigin", "EvaluationRunDataStepInputType", "EvaluationRunDataStepOutput", "EvaluationRunDataStepOutputOrigin", "EvaluationRunDataStepOutputType", "EvaluationRunEdit", "EvaluationRunFlags", "EvaluationRunIdResponse", "EvaluationRunIdsRequest", "EvaluationRunIdsResponse", "EvaluationRunQuery", "EvaluationRunQueryFlags", "EvaluationRunResponse", "EvaluationRunsResponse", "EvaluationScenario", "EvaluationScenarioCreate", "EvaluationScenarioEdit", "EvaluationScenarioIdResponse", "EvaluationScenarioIdsResponse", "EvaluationScenarioQuery", "EvaluationScenarioResponse", "EvaluationScenariosResponse", "EvaluationStatus", "Evaluator", "EvaluatorArtifactFlags", "EvaluatorArtifactQueryFlags", "EvaluatorCatalogPreset", "EvaluatorCatalogPresetResponse", "EvaluatorCatalogPresetsResponse", "EvaluatorCatalogTemplate", "EvaluatorCatalogTemplateResponse", "EvaluatorCatalogTemplatesResponse", "EvaluatorCatalogType", "EvaluatorCatalogTypesResponse", "EvaluatorCreate", "EvaluatorEdit", "EvaluatorFlags", "EvaluatorQuery", "EvaluatorResponse", "EvaluatorRevisionCommit", "EvaluatorRevisionCreate", "EvaluatorRevisionDataInput", "EvaluatorRevisionDataInputHeadersValue", "EvaluatorRevisionDataInputRuntime", "EvaluatorRevisionDataOutput", "EvaluatorRevisionDataOutputHeadersValue", "EvaluatorRevisionDataOutputRuntime", "EvaluatorRevisionEdit", "EvaluatorRevisionFlags", "EvaluatorRevisionInput", "EvaluatorRevisionOutput", "EvaluatorRevisionQuery", "EvaluatorRevisionQueryFlags", "EvaluatorRevisionResolveResponse", "EvaluatorRevisionResponse", "EvaluatorRevisionsLog", "EvaluatorRevisionsResponse", "EvaluatorTemplate", "EvaluatorTemplatesResponse", "EvaluatorVariant", "EvaluatorVariantCreate", "EvaluatorVariantEdit", "EvaluatorVariantFlags", "EvaluatorVariantFork", "EvaluatorVariantResponse", "EvaluatorVariantsResponse", "EvaluatorsResponse", "Event", "EventQuery", "EventType", "EventsQueryResponse", "ExistenceOperator", "FilteringInput", "FilteringInputConditionsItem", "FilteringOutput", "FilteringOutputConditionsItem", "Focus", "Folder", "FolderCreate", "FolderEdit", "FolderIdResponse", "FolderKind", "FolderQuery", "FolderQueryKinds", "FolderResponse", "FoldersResponse", "Format", "Formatting", typing.Any, typing.Any, "GatewayToolConfig", "GatewayToolConfigPermission", "HarnessSessionRecord", "Header", "HttpValidationError", "InviteRequest", "Invocation", "InvocationCreate", "InvocationCreateLinks", "InvocationEdit", "InvocationEditLinks", "InvocationLinkResponse", "InvocationLinks", "InvocationQuery", "InvocationQueryLinks", "InvocationResponse", "InvocationsResponse", "JsonSchemasInput", "JsonSchemasOutput", typing.Any, typing.Any, "LegacyLifecycleDto", "ListApiKeysResponse", "ListOperator", "ListOptions", "LogicalOperator", "MetricSpec", "MetricType", "MetricsBucket", "Mount", "MountCreate", "MountCredentials", "MountCredentialsResponse", "MountData", "MountEdit", "MountFileDeletedResponse", "MountFileWrittenResponse", "MountFlags", "MountFolderCreatedResponse", "MountQuery", "MountResponse", "MountsResponse", "NumericOperator", "OTelEventInput", "OTelEventInputTimestamp", "OTelEventOutput", "OTelEventOutputTimestamp", "OTelHashInput", "OTelHashOutput", "OTelLinkInput", "OTelLinkOutput", "OTelLinksResponse", "OTelReferenceInput", "OTelReferenceOutput", "OTelSpanKind", "OTelStatusCode", "OTelTracingRequest", "OTelTracingResponse", "OldAnalyticsResponse", "Organization", "OrganizationDetails", "OrganizationDomainResponse", "OrganizationProviderResponse", "OrganizationUpdate", "Permission", "PlaygroundBuildKitContext", "ProjectsResponse", "QueriesResponse", "Query", "QueryCreate", "QueryEdit", "QueryFlags", "QueryQueryFlags", "QueryResponse", "QueryRevision", "QueryRevisionCommit", "QueryRevisionCreate", "QueryRevisionDataInput", "QueryRevisionDataOutput", "QueryRevisionEdit", "QueryRevisionQuery", "QueryRevisionResponse", "QueryRevisionsLog", "QueryRevisionsResponse", "QueryVariant", "QueryVariantCreate", "QueryVariantEdit", "QueryVariantFork", "QueryVariantQuery", "QueryVariantResponse", "QueryVariantsResponse", "Reference", "ReferenceRequestModelInput", "ReferenceRequestModelOutput", "RequestType", "ResolutionInfo", "ResolvedTool", "RetrievalInfo", "SecretDto", "SecretDtoData", "SecretKind", "SecretResponseDto", "SecretResponseDtoData", "Selector", "SessionHeartbeatResponseModel", "SessionIdsResponse", "SessionInteraction", "SessionInteractionData", "SessionInteractionFlags", "SessionInteractionKind", "SessionInteractionQuery", "SessionInteractionQueryFlags", "SessionInteractionResponse", "SessionInteractionStatus", "SessionInteractionsResponse", "SessionMount", "SessionMountQuery", "SessionMountsResponse", "SessionRecord", "SessionRecordResponse", "SessionRecordsQueryResponse", "SessionState", "SessionStateData", "SessionStateFlags", "SessionStateResponse", "SessionStateUpsertRequest", "SessionStream", "SessionStreamCommandResponseModel", "SessionStreamFlags", "SessionStreamResponseModel", "SessionStreamsResponseModel", "SimpleApplication", "SimpleApplicationAdditionalContext", "SimpleApplicationCreate", "SimpleApplicationDataInput", "SimpleApplicationDataInputHeadersValue", "SimpleApplicationDataInputRuntime", "SimpleApplicationDataOutput", "SimpleApplicationDataOutputHeadersValue", "SimpleApplicationDataOutputRuntime", "SimpleApplicationEdit", "SimpleApplicationFlags", "SimpleApplicationQuery", "SimpleApplicationQueryFlags", "SimpleApplicationResponse", "SimpleApplicationsResponse", "SimpleEnvironment", "SimpleEnvironmentCreate", "SimpleEnvironmentEdit", "SimpleEnvironmentQuery", "SimpleEnvironmentResponse", "SimpleEnvironmentsResponse", "SimpleEvaluation", "SimpleEvaluationCreate", "SimpleEvaluationData", "SimpleEvaluationDataApplicationSteps", "SimpleEvaluationDataApplicationStepsOneValue", "SimpleEvaluationDataEvaluatorSteps", "SimpleEvaluationDataEvaluatorStepsOneValue", "SimpleEvaluationDataQuerySteps", "SimpleEvaluationDataQueryStepsOneValue", "SimpleEvaluationDataTestsetSteps", "SimpleEvaluationDataTestsetStepsOneValue", "SimpleEvaluationEdit", "SimpleEvaluationIdResponse", "SimpleEvaluationQuery", "SimpleEvaluationResponse", "SimpleEvaluationsResponse", "SimpleEvaluator", "SimpleEvaluatorCreate", "SimpleEvaluatorDataInput", "SimpleEvaluatorDataInputHeadersValue", "SimpleEvaluatorDataInputRuntime", "SimpleEvaluatorDataOutput", "SimpleEvaluatorDataOutputHeadersValue", "SimpleEvaluatorDataOutputRuntime", "SimpleEvaluatorEdit", "SimpleEvaluatorFlags", "SimpleEvaluatorQuery", "SimpleEvaluatorQueryFlags", "SimpleEvaluatorResponse", "SimpleEvaluatorsResponse", "SimpleQueriesResponse", "SimpleQuery", "SimpleQueryCreate", "SimpleQueryEdit", "SimpleQueryQuery", "SimpleQueryResponse", "SimpleQueue", "SimpleQueueCreate", "SimpleQueueData", "SimpleQueueDataEvaluators", "SimpleQueueDataEvaluatorsOneValue", "SimpleQueueIdResponse", "SimpleQueueIdsResponse", "SimpleQueueKind", "SimpleQueueQuery", "SimpleQueueResponse", "SimpleQueueScenariosQuery", "SimpleQueueScenariosResponse", "SimpleQueueSettings", "SimpleQueuesResponse", "SimpleTestset", "SimpleTestsetCreate", "SimpleTestsetEdit", "SimpleTestsetQuery", "SimpleTestsetResponse", "SimpleTestsetsResponse", "SimpleTrace", "SimpleTraceChannel", "SimpleTraceCreate", "SimpleTraceCreateLinks", "SimpleTraceEdit", "SimpleTraceEditLinks", "SimpleTraceKind", "SimpleTraceLinkResponse", "SimpleTraceLinks", "SimpleTraceOrigin", "SimpleTraceQuery", "SimpleTraceQueryLinks", "SimpleTraceReferences", "SimpleTraceResponse", "SimpleTracesResponse", "SimpleWorkflow", "SimpleWorkflowCreate", "SimpleWorkflowDataInput", "SimpleWorkflowDataInputHeadersValue", "SimpleWorkflowDataInputRuntime", "SimpleWorkflowDataOutput", "SimpleWorkflowDataOutputHeadersValue", "SimpleWorkflowDataOutputRuntime", "SimpleWorkflowEdit", "SimpleWorkflowFlags", "SimpleWorkflowQuery", "SimpleWorkflowQueryFlags", "SimpleWorkflowResponse", "SimpleWorkflowsResponse", "SpanInput", "SpanInputEndTime", "SpanInputStartTime", "SpanOutput", "SpanOutputEndTime", "SpanOutputStartTime", "SpanResponse", "SpanType", "SpansNodeInput", "SpansNodeInputEndTime", "SpansNodeInputSpansValue", "SpansNodeInputStartTime", "SpansNodeOutput", "SpansNodeOutputEndTime", "SpansNodeOutputSpansValue", "SpansNodeOutputStartTime", "SpansResponse", "SpansTreeInput", "SpansTreeInputSpansValue", "SpansTreeOutput", "SpansTreeOutputSpansValue", "SsoProviderDto", "SsoProviderInfo", "SsoProviderSettingsDto", "SsoProviders", "StandardProviderDto", "StandardProviderKind", "StandardProviderSettingsDto", "Status", "StringOperator", "TestcaseInput", "TestcaseOutput", "TestcaseResponse", "TestcasesResponse", "Testset", "TestsetCreate", "TestsetEdit", "TestsetFlags", "TestsetQuery", "TestsetResponse", "TestsetRevision", "TestsetRevisionCommit", "TestsetRevisionCreate", "TestsetRevisionDataInput", "TestsetRevisionDataOutput", "TestsetRevisionDelta", "TestsetRevisionDeltaColumns", "TestsetRevisionDeltaRows", "TestsetRevisionEdit", "TestsetRevisionQuery", "TestsetRevisionResponse", "TestsetRevisionsLog", "TestsetRevisionsResponse", "TestsetVariant", "TestsetVariantCreate", "TestsetVariantEdit", "TestsetVariantFork", "TestsetVariantQuery", "TestsetVariantResponse", "TestsetVariantsResponse", "TestsetsResponse", "TextOptions", "ToolAuthScheme", "ToolCallData", "ToolCallFunction", "ToolCallResponse", "ToolCatalogAction", "ToolCatalogActionDetails", "ToolCatalogActionResponse", "ToolCatalogActionResponseAction", "ToolCatalogActionsResponse", "ToolCatalogActionsResponseActionsItem", "ToolCatalogCategoriesResponse", "ToolCatalogCategory", "ToolCatalogIntegration", "ToolCatalogIntegrationDetails", "ToolCatalogIntegrationResponse", "ToolCatalogIntegrationResponseIntegration", "ToolCatalogIntegrationsResponse", "ToolCatalogIntegrationsResponseIntegrationsItem", "ToolCatalogProvider", "ToolCatalogProviderDetails", "ToolCatalogProviderResponse", "ToolCatalogProviderResponseProvider", "ToolCatalogProvidersResponse", "ToolCatalogProvidersResponseProvidersItem", "ToolConnection", "ToolConnectionCreate", "ToolConnectionCreateData", "ToolConnectionResponse", "ToolConnectionState", "ToolConnectionStatus", "ToolConnectionsResponse", "ToolProviderKind", "ToolResolveResponse", "ToolResult", "ToolResultData", "TraceIdResponse", "TraceIdsResponse", "TraceInput", "TraceInputSpansValue", "TraceOutput", "TraceOutputSpansValue", "TraceRequest", "TraceResponse", "TraceType", "TracesRequest", "TracesResponse", "TracingQuery", "TriggerAuthScheme", "TriggerCapabilitiesResult", "TriggerCapability", "TriggerCapabilityConnection", "TriggerCatalogEvent", "TriggerCatalogEventDetails", "TriggerCatalogEventResponse", "TriggerCatalogEventsResponse", "TriggerCatalogIntegration", "TriggerCatalogIntegrationResponse", "TriggerCatalogIntegrationsResponse", "TriggerCatalogProvider", "TriggerCatalogProviderResponse", "TriggerCatalogProvidersResponse", "TriggerConnectAffordance", "TriggerConnection", "TriggerConnectionCreate", "TriggerConnectionCreateData", "TriggerConnectionRequirement", "TriggerConnectionResponse", "TriggerConnectionStatus", "TriggerConnectionsResponse", "TriggerDeliveriesResponse", "TriggerDelivery", "TriggerDeliveryData", "TriggerDeliveryQuery", "TriggerDeliveryResponse", "TriggerDiscoveryConnectionState", "TriggerDiscoveryGuidance", "TriggerEventAck", "TriggerProviderKind", "TriggerSchedule", "TriggerScheduleCreate", "TriggerScheduleData", "TriggerScheduleDataInputsFields", "TriggerScheduleEdit", "TriggerScheduleFlags", "TriggerScheduleQuery", "TriggerScheduleResponse", "TriggerSchedulesResponse", "TriggerSubscription", "TriggerSubscriptionCreate", "TriggerSubscriptionCreateRequest", "TriggerSubscriptionData", "TriggerSubscriptionDataInputsFields", "TriggerSubscriptionEdit", "TriggerSubscriptionFlags", "TriggerSubscriptionQuery", "TriggerSubscriptionResponse", "TriggerSubscriptionsResponse", "UserIdsResponse", "ValidationError", "ValidationErrorLocItem", "WebhookDeliveriesResponse", "WebhookDelivery", "WebhookDeliveryCreate", "WebhookDeliveryData", "WebhookDeliveryQuery", "WebhookDeliveryResponse", "WebhookDeliveryResponseInfo", "WebhookEventType", "WebhookProviderDto", "WebhookProviderSettingsDto", "WebhookSubscription", "WebhookSubscriptionCreate", "WebhookSubscriptionData", "WebhookSubscriptionDataAuthMode", "WebhookSubscriptionEdit", "WebhookSubscriptionFlags", "WebhookSubscriptionQuery", "WebhookSubscriptionResponse", "WebhookSubscriptionsResponse", "Windowing", "WindowingOrder", "Workflow", "WorkflowArtifactFlags", "WorkflowCatalogFlags", "WorkflowCatalogHarness", "WorkflowCatalogHarnessResponse", "WorkflowCatalogHarnessesResponse", "WorkflowCatalogPreset", "WorkflowCatalogPresetResponse", "WorkflowCatalogPresetsResponse", "WorkflowCatalogTemplate", "WorkflowCatalogTemplateResponse", "WorkflowCatalogTemplatesResponse", "WorkflowCatalogType", "WorkflowCatalogTypeResponse", "WorkflowCatalogTypesResponse", "WorkflowCreate", "WorkflowEdit", "WorkflowFlags", "WorkflowResponse", "WorkflowRevisionCommit", "WorkflowRevisionCreate", "WorkflowRevisionDataInput", "WorkflowRevisionDataInputHeadersValue", "WorkflowRevisionDataInputRuntime", "WorkflowRevisionDataOutput", "WorkflowRevisionDataOutputHeadersValue", "WorkflowRevisionDataOutputRuntime", "WorkflowRevisionDelta", "WorkflowRevisionEdit", "WorkflowRevisionFlags", "WorkflowRevisionInput", "WorkflowRevisionOutput", "WorkflowRevisionResolveResponse", "WorkflowRevisionResponse", "WorkflowRevisionsLog", "WorkflowRevisionsResponse", "WorkflowVariant", "WorkflowVariantCreate", "WorkflowVariantEdit", "WorkflowVariantFlags", "WorkflowVariantFork", "WorkflowVariantResponse", "WorkflowVariantsResponse", "WorkflowsResponse", "Workspace", "WorkspaceMemberResponse", "WorkspacePermission", "WorkspaceResponse"] +__all__ = ["AdminAccountCreateOptions", "AdminAccountRead", "AdminAccountsCreate", "AdminAccountsDelete", "AdminAccountsDeleteTarget", "AdminAccountsResponse", "AdminApiKeyCreate", "AdminApiKeyResponse", "AdminDeleteResponse", "AdminDeletedEntities", "AdminDeletedEntity", "AdminOrganizationCreate", "AdminOrganizationMembershipCreate", "AdminOrganizationMembershipRead", "AdminOrganizationRead", "AdminProjectCreate", "AdminProjectMembershipCreate", "AdminProjectMembershipRead", "AdminProjectRead", "AdminSimpleAccountCreate", "AdminSimpleAccountDeleteEntry", "AdminSimpleAccountRead", "AdminSimpleAccountsApiKeysCreate", "AdminSimpleAccountsCreate", "AdminSimpleAccountsDelete", "AdminSimpleAccountsOrganizationsCreate", "AdminSimpleAccountsOrganizationsMembershipsCreate", "AdminSimpleAccountsOrganizationsTransferOwnership", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjects", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeProjectsZero", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspaces", "AdminSimpleAccountsOrganizationsTransferOwnershipIncludeWorkspacesZero", "AdminSimpleAccountsOrganizationsTransferOwnershipResponse", "AdminSimpleAccountsProjectsCreate", "AdminSimpleAccountsProjectsMembershipsCreate", "AdminSimpleAccountsResponse", "AdminSimpleAccountsUsersCreate", "AdminSimpleAccountsUsersIdentitiesCreate", "AdminSimpleAccountsUsersResetPassword", "AdminSimpleAccountsWorkspacesCreate", "AdminSimpleAccountsWorkspacesMembershipsCreate", "AdminStructuredError", "AdminSubscriptionCreate", "AdminSubscriptionRead", "AdminUserCreate", "AdminUserIdentityCreate", "AdminUserIdentityRead", "AdminUserIdentityReadStatus", "AdminUserRead", "AdminWorkspaceCreate", "AdminWorkspaceMembershipCreate", "AdminWorkspaceMembershipRead", "AdminWorkspaceRead", "AgentTemplateOverlay", "Analytics", "AnalyticsResponse", "Annotation", "AnnotationCreate", "AnnotationCreateLinks", "AnnotationEdit", "AnnotationEditLinks", "AnnotationLinkResponse", "AnnotationLinks", "AnnotationQuery", "AnnotationQueryLinks", "AnnotationResponse", "AnnotationsResponse", "Application", "ApplicationArtifactFlags", "ApplicationArtifactQueryFlags", "ApplicationCatalogPreset", "ApplicationCatalogPresetResponse", "ApplicationCatalogPresetsResponse", "ApplicationCatalogTemplate", "ApplicationCatalogTemplateResponse", "ApplicationCatalogTemplatesResponse", "ApplicationCatalogType", "ApplicationCatalogTypesResponse", "ApplicationCreate", "ApplicationEdit", "ApplicationFlags", "ApplicationQuery", "ApplicationResponse", "ApplicationRevisionCommit", "ApplicationRevisionCreate", "ApplicationRevisionDataInput", "ApplicationRevisionDataInputHeadersValue", "ApplicationRevisionDataInputRuntime", "ApplicationRevisionDataOutput", "ApplicationRevisionDataOutputHeadersValue", "ApplicationRevisionDataOutputRuntime", "ApplicationRevisionEdit", "ApplicationRevisionFlags", "ApplicationRevisionInput", "ApplicationRevisionOutput", "ApplicationRevisionQuery", "ApplicationRevisionQueryFlags", "ApplicationRevisionResolveResponse", "ApplicationRevisionResponse", "ApplicationRevisionsLog", "ApplicationRevisionsResponse", "ApplicationVariant", "ApplicationVariantCreate", "ApplicationVariantEdit", "ApplicationVariantFlags", "ApplicationVariantFork", "ApplicationVariantResponse", "ApplicationVariantsResponse", "ApplicationsResponse", "BodyConfigsFetchVariantsConfigsFetchPost", "Bucket", "BuiltinToolConfig", "BuiltinToolConfigPermission", "CapabilitiesResult", "Capability", "CapabilityConnection", "CapabilityGuidance", "CollectStatusResponse", "CommandMode", "ComparisonOperator", "Condition", "ConditionOperator", "ConditionOptions", "ConditionValue", "ConfigResponseModel", "ConnectAffordance", "ConnectionRequirement", "CustomModelSettingsDto", "CustomProviderDto", "CustomProviderKind", "CustomProviderSettingsDto", "CustomSecretDto", "CustomSecretFormat", "CustomSecretSettingsDto", "CustomSecretSettingsDtoContent", "CustomSecretSettingsDtoContentOneValue", "DictOperator", "DiscoverResponse", "DiscoverResponseMethodsValue", "DiscoveredAlternative", "DiscoveredTool", "DiscoveredToolType", "DiscoveredTriggerAlternative", "DiscoveredTriggerEvent", "DiscoveredTriggerEventType", "EntityRef", "Environment", "EnvironmentCreate", "EnvironmentEdit", "EnvironmentFlags", "EnvironmentQueryFlags", "EnvironmentResponse", "EnvironmentRevisionCommit", "EnvironmentRevisionCreate", "EnvironmentRevisionData", "EnvironmentRevisionDelta", "EnvironmentRevisionEdit", "EnvironmentRevisionInput", "EnvironmentRevisionOutput", "EnvironmentRevisionResolveResponse", "EnvironmentRevisionResponse", "EnvironmentRevisionsLog", "EnvironmentRevisionsResponse", "EnvironmentVariant", "EnvironmentVariantCreate", "EnvironmentVariantEdit", "EnvironmentVariantFork", "EnvironmentVariantResponse", "EnvironmentVariantsResponse", "EnvironmentsResponse", "ErrorPolicy", "EvaluationMetrics", "EvaluationMetricsCreate", "EvaluationMetricsIdsResponse", "EvaluationMetricsQuery", "EvaluationMetricsQueryScenarioIds", "EvaluationMetricsQueryTimestamps", "EvaluationMetricsRefresh", "EvaluationMetricsResponse", "EvaluationMetricsSetRequest", "EvaluationQueue", "EvaluationQueueCreate", "EvaluationQueueData", "EvaluationQueueEdit", "EvaluationQueueFlags", "EvaluationQueueIdResponse", "EvaluationQueueIdsResponse", "EvaluationQueueQuery", "EvaluationQueueQueryFlags", "EvaluationQueueResponse", "EvaluationQueueScenariosQuery", "EvaluationQueuesResponse", "EvaluationResult", "EvaluationResultCreate", "EvaluationResultIdResponse", "EvaluationResultIdsResponse", "EvaluationResultQuery", "EvaluationResultResponse", "EvaluationResultsResponse", "EvaluationResultsSetRequest", "EvaluationRun", "EvaluationRunCreate", "EvaluationRunDataConcurrency", "EvaluationRunDataInput", "EvaluationRunDataMapping", "EvaluationRunDataMappingColumn", "EvaluationRunDataMappingStep", "EvaluationRunDataOutput", "EvaluationRunDataStepInput", "EvaluationRunDataStepInputKey", "EvaluationRunDataStepInputOrigin", "EvaluationRunDataStepInputType", "EvaluationRunDataStepOutput", "EvaluationRunDataStepOutputOrigin", "EvaluationRunDataStepOutputType", "EvaluationRunEdit", "EvaluationRunFlags", "EvaluationRunIdResponse", "EvaluationRunIdsRequest", "EvaluationRunIdsResponse", "EvaluationRunQuery", "EvaluationRunQueryFlags", "EvaluationRunResponse", "EvaluationRunsResponse", "EvaluationScenario", "EvaluationScenarioCreate", "EvaluationScenarioEdit", "EvaluationScenarioIdResponse", "EvaluationScenarioIdsResponse", "EvaluationScenarioQuery", "EvaluationScenarioResponse", "EvaluationScenariosResponse", "EvaluationStatus", "Evaluator", "EvaluatorArtifactFlags", "EvaluatorArtifactQueryFlags", "EvaluatorCatalogPreset", "EvaluatorCatalogPresetResponse", "EvaluatorCatalogPresetsResponse", "EvaluatorCatalogTemplate", "EvaluatorCatalogTemplateResponse", "EvaluatorCatalogTemplatesResponse", "EvaluatorCatalogType", "EvaluatorCatalogTypesResponse", "EvaluatorCreate", "EvaluatorEdit", "EvaluatorFlags", "EvaluatorQuery", "EvaluatorResponse", "EvaluatorRevisionCommit", "EvaluatorRevisionCreate", "EvaluatorRevisionDataInput", "EvaluatorRevisionDataInputHeadersValue", "EvaluatorRevisionDataInputRuntime", "EvaluatorRevisionDataOutput", "EvaluatorRevisionDataOutputHeadersValue", "EvaluatorRevisionDataOutputRuntime", "EvaluatorRevisionEdit", "EvaluatorRevisionFlags", "EvaluatorRevisionInput", "EvaluatorRevisionOutput", "EvaluatorRevisionQuery", "EvaluatorRevisionQueryFlags", "EvaluatorRevisionResolveResponse", "EvaluatorRevisionResponse", "EvaluatorRevisionsLog", "EvaluatorRevisionsResponse", "EvaluatorTemplate", "EvaluatorTemplatesResponse", "EvaluatorVariant", "EvaluatorVariantCreate", "EvaluatorVariantEdit", "EvaluatorVariantFlags", "EvaluatorVariantFork", "EvaluatorVariantResponse", "EvaluatorVariantsResponse", "EvaluatorsResponse", "Event", "EventQuery", "EventType", "EventsQueryResponse", "ExistenceOperator", "FilteringInput", "FilteringInputConditionsItem", "FilteringOutput", "FilteringOutputConditionsItem", "Focus", "Folder", "FolderCreate", "FolderEdit", "FolderIdResponse", "FolderKind", "FolderQuery", "FolderQueryKinds", "FolderResponse", "FoldersResponse", "Format", "Formatting", typing.Any, typing.Any, "GatewayToolConfig", "GatewayToolConfigPermission", "HarnessKind", "Header", "HttpValidationError", "InviteRequest", "Invocation", "InvocationCreate", "InvocationCreateLinks", "InvocationEdit", "InvocationEditLinks", "InvocationLinkResponse", "InvocationLinks", "InvocationQuery", "InvocationQueryLinks", "InvocationResponse", "InvocationsResponse", "JsonSchemasInput", "JsonSchemasOutput", typing.Any, typing.Any, "LegacyLifecycleDto", "ListApiKeysResponse", "ListOperator", "ListOptions", "LogicalOperator", "MetricSpec", "MetricType", "MetricsBucket", "Mount", "MountCreate", "MountCredentials", "MountCredentialsResponse", "MountData", "MountEdit", "MountFileDeletedResponse", "MountFileWrittenResponse", "MountFlags", "MountFolderCreatedResponse", "MountQuery", "MountResponse", "MountsResponse", "NumericOperator", "OTelEventInput", "OTelEventInputTimestamp", "OTelEventOutput", "OTelEventOutputTimestamp", "OTelHashInput", "OTelHashOutput", "OTelLinkInput", "OTelLinkOutput", "OTelLinksResponse", "OTelReferenceInput", "OTelReferenceOutput", "OTelSpanKind", "OTelStatusCode", "OTelTracingRequest", "OTelTracingResponse", "OldAnalyticsResponse", "Organization", "OrganizationDetails", "OrganizationDomainResponse", "OrganizationProviderResponse", "OrganizationUpdate", "Permission", "PlaygroundBuildKitContext", "ProjectsResponse", "QueriesResponse", "Query", "QueryCreate", "QueryEdit", "QueryFlags", "QueryQueryFlags", "QueryResponse", "QueryRevision", "QueryRevisionCommit", "QueryRevisionCreate", "QueryRevisionDataInput", "QueryRevisionDataOutput", "QueryRevisionEdit", "QueryRevisionQuery", "QueryRevisionResponse", "QueryRevisionsLog", "QueryRevisionsResponse", "QueryVariant", "QueryVariantCreate", "QueryVariantEdit", "QueryVariantFork", "QueryVariantQuery", "QueryVariantResponse", "QueryVariantsResponse", "Reference", "ReferenceRequestModelInput", "ReferenceRequestModelOutput", "RequestType", "ResolutionInfo", "ResolvedTool", "RetrievalInfo", "SecretDto", "SecretDtoData", "SecretKind", "SecretResponseDto", "SecretResponseDtoData", "Selector", "SessionHeartbeatResult", "SessionIdsResponse", "SessionInteraction", "SessionInteractionData", "SessionInteractionFlags", "SessionInteractionKind", "SessionInteractionQuery", "SessionInteractionQueryFlags", "SessionInteractionResponse", "SessionInteractionStatus", "SessionInteractionsResponse", "SessionMount", "SessionMountQuery", "SessionMountsResponse", "SessionRecord", "SessionRecordResponse", "SessionRecordsQueryResponse", "SessionResponse", "SessionStream", "SessionStreamCommandResponse", "SessionStreamFlags", "SessionStreamHeaderEdit", "SessionStreamResponse", "SessionStreamsResponse", "SessionTurn", "SessionTurnQuery", "SessionTurnResponse", "SessionTurnsResponse", "SessionsResponse", "SimpleApplication", "SimpleApplicationAdditionalContext", "SimpleApplicationCreate", "SimpleApplicationDataInput", "SimpleApplicationDataInputHeadersValue", "SimpleApplicationDataInputRuntime", "SimpleApplicationDataOutput", "SimpleApplicationDataOutputHeadersValue", "SimpleApplicationDataOutputRuntime", "SimpleApplicationEdit", "SimpleApplicationFlags", "SimpleApplicationQuery", "SimpleApplicationQueryFlags", "SimpleApplicationResponse", "SimpleApplicationsResponse", "SimpleEnvironment", "SimpleEnvironmentCreate", "SimpleEnvironmentEdit", "SimpleEnvironmentQuery", "SimpleEnvironmentResponse", "SimpleEnvironmentsResponse", "SimpleEvaluation", "SimpleEvaluationCreate", "SimpleEvaluationData", "SimpleEvaluationDataApplicationSteps", "SimpleEvaluationDataApplicationStepsOneValue", "SimpleEvaluationDataEvaluatorSteps", "SimpleEvaluationDataEvaluatorStepsOneValue", "SimpleEvaluationDataQuerySteps", "SimpleEvaluationDataQueryStepsOneValue", "SimpleEvaluationDataTestsetSteps", "SimpleEvaluationDataTestsetStepsOneValue", "SimpleEvaluationEdit", "SimpleEvaluationIdResponse", "SimpleEvaluationQuery", "SimpleEvaluationResponse", "SimpleEvaluationsResponse", "SimpleEvaluator", "SimpleEvaluatorCreate", "SimpleEvaluatorDataInput", "SimpleEvaluatorDataInputHeadersValue", "SimpleEvaluatorDataInputRuntime", "SimpleEvaluatorDataOutput", "SimpleEvaluatorDataOutputHeadersValue", "SimpleEvaluatorDataOutputRuntime", "SimpleEvaluatorEdit", "SimpleEvaluatorFlags", "SimpleEvaluatorQuery", "SimpleEvaluatorQueryFlags", "SimpleEvaluatorResponse", "SimpleEvaluatorsResponse", "SimpleQueriesResponse", "SimpleQuery", "SimpleQueryCreate", "SimpleQueryEdit", "SimpleQueryQuery", "SimpleQueryResponse", "SimpleQueue", "SimpleQueueCreate", "SimpleQueueData", "SimpleQueueDataEvaluators", "SimpleQueueDataEvaluatorsOneValue", "SimpleQueueIdResponse", "SimpleQueueIdsResponse", "SimpleQueueKind", "SimpleQueueQuery", "SimpleQueueResponse", "SimpleQueueScenariosQuery", "SimpleQueueScenariosResponse", "SimpleQueueSettings", "SimpleQueuesResponse", "SimpleTestset", "SimpleTestsetCreate", "SimpleTestsetEdit", "SimpleTestsetQuery", "SimpleTestsetResponse", "SimpleTestsetsResponse", "SimpleTrace", "SimpleTraceChannel", "SimpleTraceCreate", "SimpleTraceCreateLinks", "SimpleTraceEdit", "SimpleTraceEditLinks", "SimpleTraceKind", "SimpleTraceLinkResponse", "SimpleTraceLinks", "SimpleTraceOrigin", "SimpleTraceQuery", "SimpleTraceQueryLinks", "SimpleTraceReferences", "SimpleTraceResponse", "SimpleTracesResponse", "SimpleWorkflow", "SimpleWorkflowCreate", "SimpleWorkflowDataInput", "SimpleWorkflowDataInputHeadersValue", "SimpleWorkflowDataInputRuntime", "SimpleWorkflowDataOutput", "SimpleWorkflowDataOutputHeadersValue", "SimpleWorkflowDataOutputRuntime", "SimpleWorkflowEdit", "SimpleWorkflowFlags", "SimpleWorkflowQuery", "SimpleWorkflowQueryFlags", "SimpleWorkflowResponse", "SimpleWorkflowsResponse", "SpanInput", "SpanInputEndTime", "SpanInputStartTime", "SpanOutput", "SpanOutputEndTime", "SpanOutputStartTime", "SpanResponse", "SpanType", "SpansNodeInput", "SpansNodeInputEndTime", "SpansNodeInputSpansValue", "SpansNodeInputStartTime", "SpansNodeOutput", "SpansNodeOutputEndTime", "SpansNodeOutputSpansValue", "SpansNodeOutputStartTime", "SpansResponse", "SpansTreeInput", "SpansTreeInputSpansValue", "SpansTreeOutput", "SpansTreeOutputSpansValue", "SsoProviderDto", "SsoProviderInfo", "SsoProviderSettingsDto", "SsoProviders", "StandardProviderDto", "StandardProviderKind", "StandardProviderSettingsDto", "Status", "StringOperator", "TestcaseInput", "TestcaseOutput", "TestcaseResponse", "TestcasesResponse", "Testset", "TestsetCreate", "TestsetEdit", "TestsetFlags", "TestsetQuery", "TestsetResponse", "TestsetRevision", "TestsetRevisionCommit", "TestsetRevisionCreate", "TestsetRevisionDataInput", "TestsetRevisionDataOutput", "TestsetRevisionDelta", "TestsetRevisionDeltaColumns", "TestsetRevisionDeltaRows", "TestsetRevisionEdit", "TestsetRevisionQuery", "TestsetRevisionResponse", "TestsetRevisionsLog", "TestsetRevisionsResponse", "TestsetVariant", "TestsetVariantCreate", "TestsetVariantEdit", "TestsetVariantFork", "TestsetVariantQuery", "TestsetVariantResponse", "TestsetVariantsResponse", "TestsetsResponse", "TextOptions", "ToolAuthScheme", "ToolCallData", "ToolCallFunction", "ToolCallResponse", "ToolCatalogAction", "ToolCatalogActionDetails", "ToolCatalogActionResponse", "ToolCatalogActionResponseAction", "ToolCatalogActionsResponse", "ToolCatalogActionsResponseActionsItem", "ToolCatalogCategoriesResponse", "ToolCatalogCategory", "ToolCatalogIntegration", "ToolCatalogIntegrationDetails", "ToolCatalogIntegrationResponse", "ToolCatalogIntegrationResponseIntegration", "ToolCatalogIntegrationsResponse", "ToolCatalogIntegrationsResponseIntegrationsItem", "ToolCatalogProvider", "ToolCatalogProviderDetails", "ToolCatalogProviderResponse", "ToolCatalogProviderResponseProvider", "ToolCatalogProvidersResponse", "ToolCatalogProvidersResponseProvidersItem", "ToolConnection", "ToolConnectionCreate", "ToolConnectionCreateData", "ToolConnectionResponse", "ToolConnectionState", "ToolConnectionStatus", "ToolConnectionsResponse", "ToolProviderKind", "ToolResolveResponse", "ToolResult", "ToolResultData", "TraceIdResponse", "TraceIdsResponse", "TraceInput", "TraceInputSpansValue", "TraceOutput", "TraceOutputSpansValue", "TraceRequest", "TraceResponse", "TraceType", "TracesRequest", "TracesResponse", "TracingQuery", "TriggerAuthScheme", "TriggerCapabilitiesResult", "TriggerCapability", "TriggerCapabilityConnection", "TriggerCatalogEvent", "TriggerCatalogEventDetails", "TriggerCatalogEventResponse", "TriggerCatalogEventsResponse", "TriggerCatalogIntegration", "TriggerCatalogIntegrationResponse", "TriggerCatalogIntegrationsResponse", "TriggerCatalogProvider", "TriggerCatalogProviderResponse", "TriggerCatalogProvidersResponse", "TriggerConnectAffordance", "TriggerConnection", "TriggerConnectionCreate", "TriggerConnectionCreateData", "TriggerConnectionRequirement", "TriggerConnectionResponse", "TriggerConnectionStatus", "TriggerConnectionsResponse", "TriggerDeliveriesResponse", "TriggerDelivery", "TriggerDeliveryData", "TriggerDeliveryQuery", "TriggerDeliveryResponse", "TriggerDiscoveryConnectionState", "TriggerDiscoveryGuidance", "TriggerEventAck", "TriggerProviderKind", "TriggerSchedule", "TriggerScheduleCreate", "TriggerScheduleData", "TriggerScheduleDataInputsFields", "TriggerScheduleEdit", "TriggerScheduleFlags", "TriggerScheduleQuery", "TriggerScheduleResponse", "TriggerSchedulesResponse", "TriggerSubscription", "TriggerSubscriptionCreate", "TriggerSubscriptionCreateRequest", "TriggerSubscriptionData", "TriggerSubscriptionDataInputsFields", "TriggerSubscriptionEdit", "TriggerSubscriptionFlags", "TriggerSubscriptionQuery", "TriggerSubscriptionResponse", "TriggerSubscriptionsResponse", "UserIdsResponse", "ValidationError", "ValidationErrorLocItem", "WebhookDeliveriesResponse", "WebhookDelivery", "WebhookDeliveryCreate", "WebhookDeliveryData", "WebhookDeliveryQuery", "WebhookDeliveryResponse", "WebhookDeliveryResponseInfo", "WebhookEventType", "WebhookProviderDto", "WebhookProviderSettingsDto", "WebhookSubscription", "WebhookSubscriptionCreate", "WebhookSubscriptionData", "WebhookSubscriptionDataAuthMode", "WebhookSubscriptionEdit", "WebhookSubscriptionFlags", "WebhookSubscriptionQuery", "WebhookSubscriptionResponse", "WebhookSubscriptionsResponse", "Windowing", "WindowingOrder", "Workflow", "WorkflowArtifactFlags", "WorkflowCatalogFlags", "WorkflowCatalogHarness", "WorkflowCatalogHarnessResponse", "WorkflowCatalogHarnessesResponse", "WorkflowCatalogPreset", "WorkflowCatalogPresetResponse", "WorkflowCatalogPresetsResponse", "WorkflowCatalogTemplate", "WorkflowCatalogTemplateResponse", "WorkflowCatalogTemplatesResponse", "WorkflowCatalogType", "WorkflowCatalogTypeResponse", "WorkflowCatalogTypesResponse", "WorkflowCreate", "WorkflowEdit", "WorkflowFlags", "WorkflowRequestData", "WorkflowResponse", "WorkflowRevisionCommit", "WorkflowRevisionCreate", "WorkflowRevisionDataInput", "WorkflowRevisionDataInputHeadersValue", "WorkflowRevisionDataInputRuntime", "WorkflowRevisionDataOutput", "WorkflowRevisionDataOutputHeadersValue", "WorkflowRevisionDataOutputRuntime", "WorkflowRevisionDelta", "WorkflowRevisionEdit", "WorkflowRevisionFlags", "WorkflowRevisionInput", "WorkflowRevisionOutput", "WorkflowRevisionResolveResponse", "WorkflowRevisionResponse", "WorkflowRevisionsLog", "WorkflowRevisionsResponse", "WorkflowVariant", "WorkflowVariantCreate", "WorkflowVariantEdit", "WorkflowVariantFlags", "WorkflowVariantFork", "WorkflowVariantResponse", "WorkflowVariantsResponse", "WorkflowsResponse", "Workspace", "WorkspaceMemberResponse", "WorkspacePermission", "WorkspaceResponse"] diff --git a/clients/python/agenta_client/types/harness_kind.py b/clients/python/agenta_client/types/harness_kind.py new file mode 100644 index 0000000000..5bd68558ef --- /dev/null +++ b/clients/python/agenta_client/types/harness_kind.py @@ -0,0 +1,5 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +HarnessKind = typing.Union[typing.Literal["pi_core", "claude", "pi_agenta"], typing.Any] diff --git a/clients/python/agenta_client/types/harness_session_record.py b/clients/python/agenta_client/types/harness_session_record.py deleted file mode 100644 index 2b1ecddc61..0000000000 --- a/clients/python/agenta_client/types/harness_session_record.py +++ /dev/null @@ -1,30 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel - - -class HarnessSessionRecord(UniversalBaseModel): - """ - Per-harness resume state. Value shape of `data.harness_sessions[]`. - """ - agent_session_id: typing.Optional[str] = pydantic.Field(default=None) - """ - This harness's own agentSessionId, fed to session/load on resume. - """ - - turn_index: typing.Optional[int] = pydantic.Field(default=None) - """ - Conversation turn number this harness last ran at. Load-eligible only when equal to the conversation's latest_turn_index; otherwise this harness's session file is stale (another harness ran since). - """ - - - if IS_PYDANTIC_V2: - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 - else: - class Config: - frozen = True - smart_union = True - extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/mount.py b/clients/python/agenta_client/types/mount.py index e4d1a79799..448afb7240 100644 --- a/clients/python/agenta_client/types/mount.py +++ b/clients/python/agenta_client/types/mount.py @@ -22,6 +22,7 @@ class Mount(UniversalBaseModel): id: typing.Optional[str] = None project_id: str session_id: typing.Optional[str] = None + agent_id: typing.Optional[str] = None data: typing.Optional[MountData] = None flags: typing.Optional[MountFlags] = None tags: typing.Optional[typing.Dict[str, typing.Any]] = None diff --git a/clients/python/agenta_client/types/mount_create.py b/clients/python/agenta_client/types/mount_create.py index 0684119f8d..4636a3a02d 100644 --- a/clients/python/agenta_client/types/mount_create.py +++ b/clients/python/agenta_client/types/mount_create.py @@ -12,6 +12,7 @@ class MountCreate(UniversalBaseModel): description: typing.Optional[str] = None slug: typing.Optional[str] = None session_id: typing.Optional[str] = None + agent_id: typing.Optional[str] = None flags: typing.Optional[MountFlags] = None tags: typing.Optional[typing.Dict[str, typing.Any]] = None meta: typing.Optional[typing.Dict[str, typing.Any]] = None diff --git a/clients/python/agenta_client/types/mount_query.py b/clients/python/agenta_client/types/mount_query.py index fa9ec1d0a4..808909fe51 100644 --- a/clients/python/agenta_client/types/mount_query.py +++ b/clients/python/agenta_client/types/mount_query.py @@ -8,6 +8,7 @@ class MountQuery(UniversalBaseModel): session_id: typing.Optional[str] = None + agent_id: typing.Optional[str] = None include_archived: typing.Optional[bool] = None if IS_PYDANTIC_V2: diff --git a/clients/python/agenta_client/types/session_heartbeat_result.py b/clients/python/agenta_client/types/session_heartbeat_result.py new file mode 100644 index 0000000000..eec26d3180 --- /dev/null +++ b/clients/python/agenta_client/types/session_heartbeat_result.py @@ -0,0 +1,37 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .session_stream import SessionStream + + +class SessionHeartbeatResult(UniversalBaseModel): + """ + A heartbeat's outcome: the reconciled stream plus the session's actual owner replica. + + `replica_id` is the replica that currently holds the affinity key after the claim + (this caller if it won or already held it, another replica otherwise). The runner reads + it to refuse serving a local sandbox session it does not own. + + `stream` is None when a losing replica heartbeats a session that has no row yet: it may + not create or stamp one, since that row belongs to the owner. + + `is_current_turn` (W7.4) is False when this turn_id's alive/running lock was gone or + reassigned at the moment of this beat — i.e. a cancel/steer/kill interrupted this turn + since the last heartbeat. The runner's watchdog reads this to abort the in-flight run; + without it a cancel that raced a heartbeat's nx=True re-acquire would silently re-arm the + SAME lock under the SAME turn_id and the interruption would never surface. + """ + stream: typing.Optional[SessionStream] = None + replica_id: str + is_current_turn: typing.Optional[bool] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/session_interaction.py b/clients/python/agenta_client/types/session_interaction.py index 3340a4179b..9e1b25b43a 100644 --- a/clients/python/agenta_client/types/session_interaction.py +++ b/clients/python/agenta_client/types/session_interaction.py @@ -1,5 +1,6 @@ # This file was auto-generated by Fern from our API Definition. +import datetime as dt import typing import pydantic @@ -11,13 +12,13 @@ class SessionInteraction(UniversalBaseModel): - id: typing.Optional[str] = None - created_at: typing.Optional[typing.Any] = None - updated_at: typing.Optional[typing.Any] = None - deleted_at: typing.Optional[typing.Any] = None + created_at: typing.Optional[dt.datetime] = None + updated_at: typing.Optional[dt.datetime] = None + deleted_at: typing.Optional[dt.datetime] = None created_by_id: typing.Optional[str] = None updated_by_id: typing.Optional[str] = None deleted_by_id: typing.Optional[str] = None + id: typing.Optional[str] = None project_id: typing.Optional[str] = None session_id: str turn_id: typing.Optional[str] = None diff --git a/clients/python/agenta_client/types/session_mount.py b/clients/python/agenta_client/types/session_mount.py index f4e4d100ec..e14cb79f06 100644 --- a/clients/python/agenta_client/types/session_mount.py +++ b/clients/python/agenta_client/types/session_mount.py @@ -22,6 +22,7 @@ class SessionMount(UniversalBaseModel): id: typing.Optional[str] = None project_id: str session_id: str + agent_id: typing.Optional[str] = None data: typing.Optional[MountData] = None flags: typing.Optional[MountFlags] = None tags: typing.Optional[typing.Dict[str, typing.Any]] = None diff --git a/clients/python/agenta_client/types/session_mount_query.py b/clients/python/agenta_client/types/session_mount_query.py index 911c6e6555..7b69bbf6d1 100644 --- a/clients/python/agenta_client/types/session_mount_query.py +++ b/clients/python/agenta_client/types/session_mount_query.py @@ -8,6 +8,7 @@ class SessionMountQuery(UniversalBaseModel): session_id: str + agent_id: typing.Optional[str] = None include_archived: typing.Optional[bool] = None if IS_PYDANTIC_V2: diff --git a/clients/python/agenta_client/types/session_record.py b/clients/python/agenta_client/types/session_record.py index 13809d2f47..a6f7ed9a2c 100644 --- a/clients/python/agenta_client/types/session_record.py +++ b/clients/python/agenta_client/types/session_record.py @@ -8,6 +8,12 @@ class SessionRecord(UniversalBaseModel): + created_at: typing.Optional[dt.datetime] = None + updated_at: typing.Optional[dt.datetime] = None + deleted_at: typing.Optional[dt.datetime] = None + created_by_id: typing.Optional[str] = None + updated_by_id: typing.Optional[str] = None + deleted_by_id: typing.Optional[str] = None record_id: str session_id: str project_id: str @@ -16,7 +22,8 @@ class SessionRecord(UniversalBaseModel): record_type: typing.Optional[str] = None record_source: typing.Optional[str] = None attributes: typing.Optional[typing.Dict[str, typing.Any]] = None - created_at: typing.Optional[dt.datetime] = None + turn_id: typing.Optional[str] = None + span_id: typing.Optional[str] = None if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 diff --git a/clients/python/agenta_client/types/session_heartbeat_response_model.py b/clients/python/agenta_client/types/session_response.py similarity index 79% rename from clients/python/agenta_client/types/session_heartbeat_response_model.py rename to clients/python/agenta_client/types/session_response.py index c12798d404..7cd6737bca 100644 --- a/clients/python/agenta_client/types/session_heartbeat_response_model.py +++ b/clients/python/agenta_client/types/session_response.py @@ -7,9 +7,9 @@ from .session_stream import SessionStream -class SessionHeartbeatResponseModel(UniversalBaseModel): - stream: typing.Optional[SessionStream] = None - replica_id: str +class SessionResponse(UniversalBaseModel): + count: typing.Optional[int] = None + session: typing.Optional[SessionStream] = None if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 diff --git a/clients/python/agenta_client/types/session_state.py b/clients/python/agenta_client/types/session_state.py deleted file mode 100644 index 9c31f20b15..0000000000 --- a/clients/python/agenta_client/types/session_state.py +++ /dev/null @@ -1,50 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import datetime as dt -import typing - -import pydantic -from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel -from .session_state_data import SessionStateData -from .session_state_flags import SessionStateFlags - - -class SessionState(UniversalBaseModel): - created_at: typing.Optional[dt.datetime] = None - updated_at: typing.Optional[dt.datetime] = None - deleted_at: typing.Optional[dt.datetime] = None - created_by_id: typing.Optional[str] = None - updated_by_id: typing.Optional[str] = None - deleted_by_id: typing.Optional[str] = None - id: typing.Optional[str] = pydantic.Field(default=None) - """ - Own uuid7 pk (state_id). - """ - - project_id: typing.Optional[str] = None - session_id: str = pydantic.Field() - """ - Bare session correlator (not an FK). - """ - - data: typing.Optional[SessionStateData] = pydantic.Field(default=None) - """ - Durable continuity state (resume ids + staleness guard). - """ - - sandbox_id: typing.Optional[str] = pydantic.Field(default=None) - """ - Remote sandbox id — the single source of truth resume pointer. - """ - - flags: typing.Optional[SessionStateFlags] = None - tags: typing.Optional[typing.Dict[str, typing.Any]] = None - meta: typing.Optional[typing.Dict[str, typing.Any]] = None - - if IS_PYDANTIC_V2: - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 - else: - class Config: - frozen = True - smart_union = True - extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/session_state_data.py b/clients/python/agenta_client/types/session_state_data.py deleted file mode 100644 index 09d4b57ce4..0000000000 --- a/clients/python/agenta_client/types/session_state_data.py +++ /dev/null @@ -1,40 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel -from .harness_session_record import HarnessSessionRecord - - -class SessionStateData(UniversalBaseModel): - """ - Typed shape of the `data` column: the session's durable continuity state. - - Stored in the existing `data` JSON column (no dedicated columns) — every field is - read and compared in the runner, never queried server-side, so a typed DTO gives the - contract without a schema change. - """ - latest_agent_session_id: typing.Optional[str] = pydantic.Field(default=None) - """ - The latest-run harness's agentSessionId; a fast-path mirror of harness_sessions[].agent_session_id. - """ - - latest_turn_index: typing.Optional[int] = pydantic.Field(default=None) - """ - Conversation-level turn counter compared against each harness's turn_index. - """ - - harness_sessions: typing.Optional[typing.Dict[str, typing.Optional[HarnessSessionRecord]]] = pydantic.Field(default=None) - """ - Per-harness resume state, keyed by harness id (e.g. 'claude', 'pi'). Durable mirror of the staleness guard. - """ - - - if IS_PYDANTIC_V2: - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 - else: - class Config: - frozen = True - smart_union = True - extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/session_state_upsert_request.py b/clients/python/agenta_client/types/session_state_upsert_request.py deleted file mode 100644 index 1876845536..0000000000 --- a/clients/python/agenta_client/types/session_state_upsert_request.py +++ /dev/null @@ -1,33 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel -from .session_state_data import SessionStateData - - -class SessionStateUpsertRequest(UniversalBaseModel): - data: typing.Optional[SessionStateData] = pydantic.Field(default=None) - """ - Full replacement of the continuity state (resume ids + staleness guard). - """ - - sandbox_id: typing.Optional[str] = pydantic.Field(default=None) - """ - Remote sandbox id to record alongside the continuity state. - """ - - sandbox_turn_index: typing.Optional[int] = pydantic.Field(default=None) - """ - the writer's conversation turn index; the pointer write is applied only when it is >= the row's data.latest_turn_index. - """ - - - if IS_PYDANTIC_V2: - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 - else: - class Config: - frozen = True - smart_union = True - extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/session_stream.py b/clients/python/agenta_client/types/session_stream.py index 8809e57195..f489faa8ba 100644 --- a/clients/python/agenta_client/types/session_stream.py +++ b/clients/python/agenta_client/types/session_stream.py @@ -9,13 +9,15 @@ class SessionStream(UniversalBaseModel): - id: str created_at: typing.Optional[dt.datetime] = None updated_at: typing.Optional[dt.datetime] = None deleted_at: typing.Optional[dt.datetime] = None created_by_id: typing.Optional[str] = None updated_by_id: typing.Optional[str] = None deleted_by_id: typing.Optional[str] = None + name: typing.Optional[str] = None + description: typing.Optional[str] = None + id: typing.Optional[str] = None project_id: str session_id: str flags: typing.Optional[SessionStreamFlags] = None diff --git a/clients/python/agenta_client/types/session_stream_command_response_model.py b/clients/python/agenta_client/types/session_stream_command_response.py similarity index 91% rename from clients/python/agenta_client/types/session_stream_command_response_model.py rename to clients/python/agenta_client/types/session_stream_command_response.py index dc5bd2abdd..8e1c27959c 100644 --- a/clients/python/agenta_client/types/session_stream_command_response_model.py +++ b/clients/python/agenta_client/types/session_stream_command_response.py @@ -7,7 +7,7 @@ from .command_mode import CommandMode -class SessionStreamCommandResponseModel(UniversalBaseModel): +class SessionStreamCommandResponse(UniversalBaseModel): mode: CommandMode session_id: str turn_id: typing.Optional[str] = None diff --git a/clients/python/agenta_client/types/session_stream_header_edit.py b/clients/python/agenta_client/types/session_stream_header_edit.py new file mode 100644 index 0000000000..fd49999542 --- /dev/null +++ b/clients/python/agenta_client/types/session_stream_header_edit.py @@ -0,0 +1,25 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class SessionStreamHeaderEdit(UniversalBaseModel): + """ + The rename edit: a full-PUT of the header fields only. + + Distinct from SessionStreamEdit (used by the flag-mirror/heartbeat paths) so the + liveness-only writes can never carry name/description, and vice versa. + """ + name: typing.Optional[str] = None + description: typing.Optional[str] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/session_stream_response_model.py b/clients/python/agenta_client/types/session_stream_response.py similarity index 91% rename from clients/python/agenta_client/types/session_stream_response_model.py rename to clients/python/agenta_client/types/session_stream_response.py index e064d078ba..960153ca32 100644 --- a/clients/python/agenta_client/types/session_stream_response_model.py +++ b/clients/python/agenta_client/types/session_stream_response.py @@ -7,7 +7,7 @@ from .session_stream import SessionStream -class SessionStreamResponseModel(UniversalBaseModel): +class SessionStreamResponse(UniversalBaseModel): stream: typing.Optional[SessionStream] = None if IS_PYDANTIC_V2: diff --git a/clients/python/agenta_client/types/session_streams_response_model.py b/clients/python/agenta_client/types/session_streams_response.py similarity index 91% rename from clients/python/agenta_client/types/session_streams_response_model.py rename to clients/python/agenta_client/types/session_streams_response.py index 53a1cab65e..a938580be2 100644 --- a/clients/python/agenta_client/types/session_streams_response_model.py +++ b/clients/python/agenta_client/types/session_streams_response.py @@ -7,7 +7,7 @@ from .session_stream import SessionStream -class SessionStreamsResponseModel(UniversalBaseModel): +class SessionStreamsResponse(UniversalBaseModel): count: int streams: typing.List[SessionStream] diff --git a/clients/python/agenta_client/types/session_turn.py b/clients/python/agenta_client/types/session_turn.py new file mode 100644 index 0000000000..6ae6d39525 --- /dev/null +++ b/clients/python/agenta_client/types/session_turn.py @@ -0,0 +1,39 @@ +# This file was auto-generated by Fern from our API Definition. + +import datetime as dt +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .harness_kind import HarnessKind +from .reference import Reference + + +class SessionTurn(UniversalBaseModel): + created_at: typing.Optional[dt.datetime] = None + updated_at: typing.Optional[dt.datetime] = None + deleted_at: typing.Optional[dt.datetime] = None + created_by_id: typing.Optional[str] = None + updated_by_id: typing.Optional[str] = None + deleted_by_id: typing.Optional[str] = None + id: typing.Optional[str] = None + project_id: str + session_id: str + stream_id: str + turn_index: int + harness_kind: HarnessKind + agent_session_id: typing.Optional[str] = None + sandbox_id: typing.Optional[str] = None + references: typing.Optional[typing.List[Reference]] = None + trace_id: typing.Optional[str] = None + span_id: typing.Optional[str] = None + start_time: typing.Optional[dt.datetime] = None + end_time: typing.Optional[dt.datetime] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/session_turn_query.py b/clients/python/agenta_client/types/session_turn_query.py new file mode 100644 index 0000000000..420348e266 --- /dev/null +++ b/clients/python/agenta_client/types/session_turn_query.py @@ -0,0 +1,23 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .harness_kind import HarnessKind +from .reference import Reference + + +class SessionTurnQuery(UniversalBaseModel): + session_id: typing.Optional[str] = None + stream_id: typing.Optional[str] = None + harness_kind: typing.Optional[HarnessKind] = None + references: typing.Optional[typing.List[Reference]] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/session_state_response.py b/clients/python/agenta_client/types/session_turn_response.py similarity index 77% rename from clients/python/agenta_client/types/session_state_response.py rename to clients/python/agenta_client/types/session_turn_response.py index a932d1413b..4872017d58 100644 --- a/clients/python/agenta_client/types/session_state_response.py +++ b/clients/python/agenta_client/types/session_turn_response.py @@ -4,12 +4,12 @@ import pydantic from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel -from .session_state import SessionState +from .session_turn import SessionTurn -class SessionStateResponse(UniversalBaseModel): +class SessionTurnResponse(UniversalBaseModel): count: typing.Optional[int] = None - session_state: typing.Optional[SessionState] = None + turn: typing.Optional[SessionTurn] = None if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 diff --git a/clients/python/agenta_client/types/session_state_flags.py b/clients/python/agenta_client/types/session_turns_response.py similarity index 71% rename from clients/python/agenta_client/types/session_state_flags.py rename to clients/python/agenta_client/types/session_turns_response.py index 09cd4194b8..e75756ceb2 100644 --- a/clients/python/agenta_client/types/session_state_flags.py +++ b/clients/python/agenta_client/types/session_turns_response.py @@ -4,9 +4,12 @@ import pydantic from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .session_turn import SessionTurn -class SessionStateFlags(UniversalBaseModel): +class SessionTurnsResponse(UniversalBaseModel): + count: typing.Optional[int] = None + turns: typing.Optional[typing.List[SessionTurn]] = None if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 diff --git a/clients/python/agenta_client/types/sessions_response.py b/clients/python/agenta_client/types/sessions_response.py new file mode 100644 index 0000000000..3351dcd5ca --- /dev/null +++ b/clients/python/agenta_client/types/sessions_response.py @@ -0,0 +1,20 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .session_stream import SessionStream + + +class SessionsResponse(UniversalBaseModel): + count: typing.Optional[int] = None + sessions: typing.Optional[typing.List[SessionStream]] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/span_input.py b/clients/python/agenta_client/types/span_input.py index 33bbc09401..8216691c6c 100644 --- a/clients/python/agenta_client/types/span_input.py +++ b/clients/python/agenta_client/types/span_input.py @@ -37,6 +37,9 @@ class SpanInput(UniversalBaseModel): end_time: typing.Optional[SpanInputEndTime] = None status_code: typing.Optional[OTelStatusCode] = None status_message: typing.Optional[str] = None + session_id: typing.Optional[str] = None + user_id: typing.Optional[str] = None + agent_id: typing.Optional[str] = None attributes: typing.Optional[typing.Dict[str, typing.Any]] = None references: typing.Optional[typing.List[OTelReferenceInput]] = None links: typing.Optional[typing.List[OTelLinkInput]] = None diff --git a/clients/python/agenta_client/types/span_output.py b/clients/python/agenta_client/types/span_output.py index 0f06f0a74a..a68d1db012 100644 --- a/clients/python/agenta_client/types/span_output.py +++ b/clients/python/agenta_client/types/span_output.py @@ -37,6 +37,9 @@ class SpanOutput(UniversalBaseModel): end_time: typing.Optional[SpanOutputEndTime] = None status_code: typing.Optional[OTelStatusCode] = None status_message: typing.Optional[str] = None + session_id: typing.Optional[str] = None + user_id: typing.Optional[str] = None + agent_id: typing.Optional[str] = None attributes: typing.Optional[typing.Dict[str, typing.Any]] = None references: typing.Optional[typing.List[OTelReferenceOutput]] = None links: typing.Optional[typing.List[OTelLinkOutput]] = None diff --git a/clients/python/agenta_client/types/spans_node_input.py b/clients/python/agenta_client/types/spans_node_input.py index 2c357d0780..8b84e6c2e5 100644 --- a/clients/python/agenta_client/types/spans_node_input.py +++ b/clients/python/agenta_client/types/spans_node_input.py @@ -38,6 +38,9 @@ class SpansNodeInput(UniversalBaseModel): end_time: typing.Optional[SpansNodeInputEndTime] = None status_code: typing.Optional[OTelStatusCode] = None status_message: typing.Optional[str] = None + session_id: typing.Optional[str] = None + user_id: typing.Optional[str] = None + agent_id: typing.Optional[str] = None attributes: typing.Optional[typing.Dict[str, typing.Any]] = None references: typing.Optional[typing.List[OTelReferenceInput]] = None links: typing.Optional[typing.List[OTelLinkInput]] = None diff --git a/clients/python/agenta_client/types/spans_node_output.py b/clients/python/agenta_client/types/spans_node_output.py index 857dbd3fbc..2fedad9a4c 100644 --- a/clients/python/agenta_client/types/spans_node_output.py +++ b/clients/python/agenta_client/types/spans_node_output.py @@ -38,6 +38,9 @@ class SpansNodeOutput(UniversalBaseModel): end_time: typing.Optional[SpansNodeOutputEndTime] = None status_code: typing.Optional[OTelStatusCode] = None status_message: typing.Optional[str] = None + session_id: typing.Optional[str] = None + user_id: typing.Optional[str] = None + agent_id: typing.Optional[str] = None attributes: typing.Optional[typing.Dict[str, typing.Any]] = None references: typing.Optional[typing.List[OTelReferenceOutput]] = None links: typing.Optional[typing.List[OTelLinkOutput]] = None diff --git a/clients/python/agenta_client/types/workflow_request_data.py b/clients/python/agenta_client/types/workflow_request_data.py new file mode 100644 index 0000000000..af9a5ea82d --- /dev/null +++ b/clients/python/agenta_client/types/workflow_request_data.py @@ -0,0 +1,23 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class WorkflowRequestData(UniversalBaseModel): + revision: typing.Optional[typing.Dict[str, typing.Any]] = None + parameters: typing.Optional[typing.Dict[str, typing.Any]] = None + testcase: typing.Optional[typing.Dict[str, typing.Any]] = None + inputs: typing.Optional[typing.Dict[str, typing.Any]] = None + trace: typing.Optional[typing.Dict[str, typing.Any]] = None + outputs: typing.Optional[typing.Any] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/docs/design/agent-workflows/projects/approvals-incident-fixes/README.md b/docs/design/agent-workflows/projects/approvals-incident-fixes/README.md new file mode 100644 index 0000000000..ec2842ba9b --- /dev/null +++ b/docs/design/agent-workflows/projects/approvals-incident-fixes/README.md @@ -0,0 +1,50 @@ +# Concurrent approvals incident fixes + +This workspace plans the fixes for the concurrent human-approval failure observed in live +session `db58551b` on 2026-07-19, plus the session-turns counter bug found in the same logs. +The incident and its seven defects are documented and log-verified in +`docs/design/agent-workflows/scratch/debug-concurrent-approvals-db58551b.md`; the design +principles we adopt from Zed's handling of the same problem are in +`docs/design/agent-workflows/scratch/zed-acp-approvals-comparison.md`; the counter bug is in +`docs/design/agent-workflows/scratch/debug-session-turns-append-500.md`. + +## Reading order + +1. `context.md` explains why this work exists, what it must achieve, and what is + deliberately out of scope. +2. `research.md` holds the code-verified findings (R1 through R8) that the plan is built + on, with file and line evidence for every claim, and a prominent list of open risks. +3. `plan.md` is the implementation plan: five ordered steps, each with exact files, + behavioral contracts, acceptance criteria, and test commands. Each step is independently + landable. The implementer is expected to work from this file without having read the + incident conversation. +4. `qa.md` is the live QA script that reproduces the incident shape against the dev stack + and states the expected correct behavior at each point. +5. `status.md` tracks where the project stands. + +## Glossary + +Every domain term used in this workspace, one line each. + +- **Runner**: the Node/TypeScript sidecar under `services/runner/` that drives a coding-agent + harness inside a sandbox and streams events to the web UI. +- **Harness**: the agent program that implements the model-and-tool loop, such as Pi + (`pi_core`) or Claude Code (`claude`). The runner talks to it over ACP, the Agent Client + Protocol. +- **Gate**: a human-approval request. When the harness wants to run a permission-gated tool, + the runner shows the user an approval card and waits for allow or deny. +- **Park**: ending the browser-facing turn while an unanswered gate keeps the live harness + process waiting. The answer arrives in a later request. +- **Warm resume**: continuing a parked session in place. The runner checks the live process + out of the keepalive pool and answers the original permission request by its id, so the + approved tool runs with its original arguments. +- **Keepalive pool**: the runner's bounded collection of live parked sessions, each with a + time-to-live limit after which it is destroyed. +- **Records**: the durable per-session event stream. The runner posts every agent event to + the API's record-ingest endpoint; the frontend rebuilds a conversation from these rows + (that rebuild is called hydration). +- **Interactions**: the durable table of human-in-the-loop requests. Each gate creates one + interaction row with a lifecycle status (pending, responded, resolved, cancelled). +- **Turns**: the append-only `session_turns` table. One row per completed conversation turn, + carrying the harness's native session id so a restarted runner can resume the conversation + natively instead of replaying text. diff --git a/docs/design/agent-workflows/projects/approvals-incident-fixes/context.md b/docs/design/agent-workflows/projects/approvals-incident-fixes/context.md new file mode 100644 index 0000000000..521044bcca --- /dev/null +++ b/docs/design/agent-workflows/projects/approvals-incident-fixes/context.md @@ -0,0 +1,58 @@ +# Context: why this work exists + +## The incident in three sentences + +During live QA of two parallel permission-gated shell commands (session `db58551b`, +2026-07-19), both commands executed exactly once and only after their approvals, but the +system misreported both, flipped an already-approved card back to "waiting", and then went +silent because the user's final approval was never sent to the runner. The root causes are +that the durable session record stores every approval request but never any approval answer, +that the post-pause cleanup invents tool results (a false "not executed" error for an +approved running call, and a false success for a call that never started), and that the +frontend only dispatches approvals once every visible card looks settled, a condition that +can never hold after a state rebuild. A seventh, unrelated defect found in the same logs +makes the new `session_turns` ingestion fail with HTTP 500 on every warm turn, because the +turn index is computed once per environment acquire instead of once per turn. + +## Goals + +1. Fix the session-turns counter so `turn_index` is a true conversation-turn counter, and + make the API answer a duplicate append with 409 Conflict instead of 500. +2. Stop the pause cleanup from inventing tool results: an approved, executing call keeps its + real result, and a never-started call is recorded as deferred, never as success. +3. Persist the answer half of every gate (a new `interaction_response` record event plus the + allow/deny verdict in the interaction row's existing `resolution` field), and make + frontend hydration overlay answers onto requests, so a rebuilt conversation shows + answered cards as answered. +4. Dispatch each approval per card the moment the user answers it, and make the runner + accept a resume that answers only part of the parked gates while remaining parked on the + rest. +5. Add a regression test that reproduces the exact incident shape, then verify with live QA + on the dev stack. + +## Non-goals + +Two related fixes are deliberately out of scope here. + +- **Real turn cancellation** (the Zed-style cancel-then-restart when new user text arrives + while gates are parked, which fixes defect 6 of the incident report) goes to Arda after + JP's sessions PRs merge, because it depends on the session-streams control plane those PRs + introduce and is partly a product decision. +- **Audit-hardening of record ids** (scoping stable record ids by turn so contradictory + results append instead of overwriting, defect 5) is queued separately, because the answer + persistence in this project already fixes the user-facing rebuild problem and the id + rescope touches the record identity contract end to end. + +## Constraints already decided + +- Work lands in this order, on these lanes: the counter fix on the existing rebase lanes for + PR #5376 (runner, branch `sessions-rebase/runner`) and PR #5375 (backend, branch + `sessions-rebase/backend`), with a wipe of the wrongly numbered dev rows and an + explanatory note for JP; everything else on the PR #5382 lane (branch + `plan/concurrent-approvals`). +- `turn_index` is confirmed to be a true conversation-turn counter, not an acquire counter. + The implementation must carry a code comment stating this invariant, and the PR body must + explain what was done and what was assumed. +- The implementation will be done by an agent that has not read the incident conversation, + so every step in `plan.md` is specified with exact files, behavioral contracts, and + acceptance criteria. diff --git a/docs/design/agent-workflows/projects/approvals-incident-fixes/plan.md b/docs/design/agent-workflows/projects/approvals-incident-fixes/plan.md new file mode 100644 index 0000000000..66eab82aa2 --- /dev/null +++ b/docs/design/agent-workflows/projects/approvals-incident-fixes/plan.md @@ -0,0 +1,547 @@ +# Implementation plan + +Five ordered steps. Each step is independently landable and independently testable. Read +`context.md` for why, and `research.md` for the evidence behind every mechanic referenced +here. Line numbers refer to the current workspace working tree; on the individual branches +the same regions exist at nearby lines (verified in research.md R8). + +Conventions that apply to every step: + +- All version control goes through GitButler (`but`), never raw git branch/commit + commands. The repository root `AGENTS.md` documents the commands and the + one-lane-at-a-time isolation procedure. Because this project and JP's rebase lanes share + files (research.md R8), you MUST: assign exactly one lane's files at a time, commit with + `but commit --only`, then verify with `git show --stat --name-only ` AND + `git diff --name-only ..` (base is the branch below the lane) before + starting the next lane. If a file from another lane appears, stop and fix before + continuing. +- Code comments explain why and invariants in one or two sentences. No session narration. +- Runner checks before every push: `pnpm test` and `pnpm run typecheck` from + `services/runner`. API checks: `ruff format` then `ruff check --fix` from `api`, then + the API unit tests via `cd api && py-run-tests` where a step says so. Web checks: + `pnpm lint-fix` from `web`. +- Do not merge anything. Each step ends at green tests plus a pushed lane; merging is + Mahmoud's action. + +## Step 1: session-turns counter fix and 409 mapping + +Purpose: `session_turns` appends currently 500 on every warm turn because the turn index is +computed once per environment acquire instead of once per turn, and the API treats the +resulting unique-key violation as an unknown error. After this step the index is a true +conversation-turn counter and a duplicate append reads as a 409 Conflict. + +Lanes: the runner half lands on `sessions-rebase/runner` (PR #5376); the backend half lands +on `sessions-rebase/backend` (PR #5375). Both are amendments to JP's open rebase PRs, so +each half is committed to its lane and the PR bodies get a short addendum (text below). + +### 1a. Runner: compute the turn index per turn + +Files: `services/runner/src/engines/sandbox_agent/environment.ts`, +`services/runner/src/engines/sandbox_agent/run-turn.ts`, +`services/runner/tests/unit/session-continuity-durable.test.ts` (or a sibling unit file if +a dispatch-level test fits better there). + +Contract: + +- Delete the acquire-time assignment `environment.continuityTurnIndex = ...` at + `environment.ts:962-964`. The durable hydrate call just above it (lines 941-949) stays: + it seeds the shared store after a runner restart and must keep running at acquire. +- At the start of `runTurn` (`run-turn.ts`, alongside the per-turn resets at lines 88-95), + set `env.continuityTurnIndex` by calling `nextTurnIndex(env.sessionId, store)` where + `store` is `deps.sessionContinuityStore ?? sessionContinuityStore` (the same fallback the + record call at line 572 uses). When `env.sessionId` is empty, set it to `undefined`, + matching the old acquire-time behavior for sessionless runs. +- Add this code comment at the new computation, stating the invariant and the assumption + (required verbatim in substance, wording may be tightened): + "`turn_index` is a true conversation-turn counter for this session, not an acquire + counter: it must advance once per COMPLETED turn, shared across every environment that + serves the session. The store only advances on `record()` (a paused turn records + nothing), so a park-and-resume cycle spanning several runner turns consumes one index. + Computed at turn start, not at environment acquire, because a warm pooled environment + serves many turns." +- Everything downstream is unchanged: the completed-turn record and the durable append + (`run-turn.ts:566-597`) keep reading `env.continuityTurnIndex`. + +Behavior that must hold (and be pinned by tests): + +- A fresh session's first completed turn appends index 0; each later completed turn served + by the SAME warm environment appends 1, 2, 3 in order. +- Two environments serving one session (one approval-parked, one idle, the `poolSize=2` + shape) interleave completed turns with strictly increasing indexes, because both read + the shared process-wide store at turn start. +- A turn that ends paused appends nothing; the resume turn that completes the same + conversation turn appends the index the paused turn would have used. + +Tests: extend `tests/unit/session-continuity-durable.test.ts` (or the orchestration test +if the seam fits better) with the three behaviors above; the two-environment case is the +one named in the 500 report's open questions. Run `pnpm test` and `pnpm run typecheck` +from `services/runner`. + +### 1b. Backend: map the duplicate append to 409 + +Files: `api/oss/src/dbs/postgres/sessions/turns/dao.py`, plus a unit or integration test in +the API test tree if one covers the turns DAO (add a narrow one if none exists). + +Contract: + +- In `SessionTurnsDAO.append` (`dao.py:33-50`), wrap the add-and-commit in + `try/except IntegrityError`. On a violation of + `ix_session_turns_project_id_session_id_turn_index`, roll back and raise + `EntityCreationConflict` (from `api/oss/src/core/shared/exceptions.py`) with + `entity="Session turn"`, a message naming the session and turn index, and a `conflict` + dict carrying `session_id` and `turn_index`. Re-raise any other IntegrityError. Copy the + established pattern from `api/oss/src/dbs/postgres/sessions/streams/dao.py:51-61` (same + domain) or `api/oss/src/dbs/postgres/gateway/connections/dao.py:70-83`. +- No router change: `append_turn` is already wrapped in `@intercept_exceptions()` + (`api/oss/src/apis/fastapi/sessions/router.py:1070`), which converts + `EntityCreationConflict` to HTTP 409 (`api/oss/src/utils/exceptions.py:132-144`). + +Acceptance: POSTing the same `(session_id, turn_index)` twice returns 200 then 409, and +the API error log shows no traceback for the second call. The runner side needs no change +for this: its append helper already logs non-OK statuses as `append HTTP ` +(`session-continuity-durable.ts:190-192`), which becomes diagnosable on sight. + +Run `ruff format`, `ruff check --fix`, and the API tests from `api`. + +### 1c. Operational: wipe the wrongly numbered dev rows + +Every `session_turns` row written while the bug was live carries an acquire-counter index, +so the dev table is wiped, not repaired. On the dev stack's EE database (database +`agenta_ee_core`, credentials `username:password` per the root `AGENTS.md`): + +```sql +-- Inspect first: every row predating the fix is suspect. +SELECT count(*) FROM session_turns; +-- Wipe. +DELETE FROM session_turns; +``` + +Run this AFTER the runner fix is deployed to the dev stack, so no old runner re-inserts +wrong indexes. Effect: the next turn of any existing session cold-replays once (the +continuity lookup finds no row) and then rebuilds correct rows going forward. That is the +table's designed degradation, not data loss. + +### 1d. The note for JP + +Append to the PR #5376 description (and reference from #5375): + +> Two amendments landed on these lanes after live QA. First, the runner computed +> `turn_index` once per environment acquire, so every warm turn re-inserted the same index +> and the append 500ed (`ix_session_turns_project_id_session_id_turn_index`); the index is +> now computed at turn start from the shared continuity store, which also fixes the +> two-pooled-environments case. We confirmed the intended semantics: `turn_index` is a true +> conversation-turn counter, and the code now carries that invariant as a comment. Second, +> the turns DAO now maps the duplicate-key IntegrityError to `EntityCreationConflict`, so a +> duplicate append reads as 409 instead of an anonymous 500. The dev database's +> `session_turns` table was wiped because every row written while the bug was live carried +> acquire-counter indexes; sessions rebuild their rows on their next completed turn. + +## Step 2: stop the pause cleanup from inventing tool results + +Purpose: after this step, the post-pause sweep can only settle calls that never executed +and hold no approval; an approved, executing call keeps its real result; and a +cancellation-closure `completed` frame for a never-started call is recorded as deferred, +never as success. This removes incident defects 2 and 3 and, with them, the false +"retry the same call" invitation on commands that actually ran. + +Lane: `plan/concurrent-approvals` (PR #5382). + +Files: `services/runner/src/engines/sandbox_agent/run-turn.ts`, +`services/runner/src/engines/sandbox_agent/pause.ts`, +`services/runner/src/engines/sandbox_agent/runtime-policy.ts`, +`services/runner/src/engines/sandbox_agent/acp-interactions.ts`, +`services/runner/src/tracing/otel.ts`, `services/runner/src/responder.ts`, +`services/runner/tests/unit/sandbox-agent-orchestration.test.ts`, +`services/runner/tests/unit/session-keepalive-approval.test.ts`, +`services/runner/tests/unit/responder.test.ts`, +`services/runner/tests/unit/pending-approval-pause.test.ts`. + +### 2a. Track allowed executions by tool-call id + +Add a per-turn id set of calls whose execution this turn legitimately allowed. Suggested +home: the pause controller (`pause.ts`), next to `pausedToolCallIds`, as +`allowedExecutionToolCallIds` with `markAllowedExecution(toolCallId)` and +`isAllowedExecution(toolCallId)`. Populate it from both allow paths: + +- The warm-resume loop: for each `decision.reply === "once"`, mark + `decision.toolCallId` (`run-turn.ts:463-465`, where the execution grant is already + recorded). +- The in-turn allow path: `replyPermission` in `acp-interactions.ts:228-252` receives the + decision and the tool-call id; on `decision === "allow"`, invoke a new optional callback + (wired from `run-turn.ts` the same way `onPausedToolCall` is) that marks the id. This + covers auto-allowed and stored-decision-allowed calls, which can also be mid-execution + when a sibling gate pauses the turn. + +### 2b. Exclude allowed executions from both sweeps + +Both `settleOpenToolCalls` call sites currently exclude only paused gates. Change the +predicate at `run-turn.ts:232-237` (the in-band re-sweep) and `run-turn.ts:505-511` (the +post-drain sweep) to exclude a call when `pause.isPausedToolCall(id) || +pause.isAllowedExecution(id)`. + +### 2c. Let an allowed execution's real terminal frame land + +Two changes: + +- `shouldSuppressPausedToolCallUpdate` (`runtime-policy.ts:31-60`) currently suppresses + every `failed` frame while the pause is active. Exempt allowed-execution ids: their real + failure is genuine evidence and must stream through. (Their `completed` frames already + pass.) The function needs access to the allowed set; pass the pause controller as today + and read the new method. +- After the post-drain point (`run-turn.ts:505-511`), before the sweep runs, wait for every + id in the allowed set that is STILL OPEN in the tracer to reach its own terminal frame. + Expose the open-call ids from the tracer (a new `openToolCallIds(): string[]` on + `SandboxAgentOtel`, reading `toolSpans` keys, `otel.ts:1128-1131`) and await closure with + a bounded wait: per call, at most the configured per-tool-call limit from + `run-limits.ts` (read the same config value; do NOT re-arm the run-limits deadlines, + which `notePaused` retired at `run-turn.ts:188` because a human pause is legitimate). + The turn's sink is still active during this wait, so the arriving result flows through + `handleUpdate` and `maybeCloseTool` normally and is never dropped as a between-turns + event. +- If the bound expires for a call, settle THAT call with a new sentinel exported next to + the existing one in `otel.ts`: + `APPROVED_EXECUTION_RESULT_UNKNOWN: the approved call started but its result was not + observed before the pause ended the turn; do not assume it failed and do not retry a + side-effecting call.` Record it as `isError: true`. It must NOT begin with the + `DEFERRED_NOT_EXECUTED` prefix (that prefix means "never executed, retry is safe"). +- Exclude the new sentinel from the client-output store the same way the deferred one is + excluded (`responder.ts:398-401` and `491-496`): a sibling settled with either sentinel + must never fulfill a later identical call. + +### 2d. Record a never-started call as deferred, not success + +While the pause is active, a `completed` frame can be a cancellation-closure artifact for a +call that never ran (incident defect 3: a `"(no output)"` success for a command that was +never approved). Classify by fail-closed policy evidence: + +- Buffer `completed` frames that arrive while `pause.active` for calls that are not paused + gates and not allowed executions, instead of letting `maybeCloseTool` record them + immediately. Suggested seam: in `run-turn.ts`'s `handleUpdate` (lines 196-239), before + `run.handleUpdate(update)`. +- When `pause.waitForEventDrain()` resolves (the same point the sweep runs today), settle + each buffered frame: if its call became a paused gate during the drain, drop the frame + (the gate's card is the last word for that call this turn); if its call became an + allowed execution, deliver the frame (real result); otherwise, if the call's effective + permission is `ask` or `deny` (resolve it the same way the gate descriptor does, via the + turn's `permissionsFromRequest` plan and `toolSpecsByName`, both already in scope in + `runTurn`), record the deferred sentinel `TOOL_NOT_EXECUTED_PAUSED` for it, because a + fail-closed gate that was never answered allow cannot have executed; if the effective + permission is `allow`, deliver the frame (an auto-allowed sibling that legitimately + finished). +- Carry a code comment stating the invariant: "Execution of an ask-policy call requires an + answered allow; both harness gate paths fail closed. A completed frame during a pause + for an unanswered ask-policy call is therefore a cancellation-closure artifact, not + evidence of execution." + +### 2e. Model-transcript guarantee + +Add one assertion-level check (a debug log is enough, a throw is not wanted): at +terminalization of a paused turn, after the sweep, the tracer holds no open calls except +paused gates. Combined with 2c and 2d this preserves the requirement that every tool call +the model ever sees eventually carries some result on the replay-fallback path, and the +warm and cold-native paths never consume runner-side records (research.md R5). + +### Acceptance criteria for step 2 + +- Existing tests still pass, specifically `sandbox-agent-orchestration.test.ts:1753` (two + racing gates both card, neither settled), the non-gated-sibling settle test around + line 1690, and `responder.test.ts:679`. +- New unit tests pin: (1) an allowed-execution call is never stamped with + `TOOL_NOT_EXECUTED_PAUSED` by either sweep; (2) its real `completed` frame arriving after + the pause but before terminalization is recorded as its result; (3) its real `failed` + frame is not suppressed; (4) the bounded wait expiring records the + `APPROVED_EXECUTION_RESULT_UNKNOWN` sentinel and neither sentinel enters the + client-output store; (5) a `completed` frame during a pause for an unanswered ask-policy + call is recorded as `TOOL_NOT_EXECUTED_PAUSED`, while the same frame for an allow-policy + call keeps its real result. +- Test commands: `cd services/runner && pnpm test && pnpm run typecheck`. + +## Step 3: persist the answer half of every gate + +Purpose: after this step, every resolved gate leaves two durable traces: an +`interaction_response` event in the session records stream, and the allow/deny verdict in +the interaction row's `resolution` field. Frontend hydration overlays answers onto +requests, so a rebuilt conversation renders answered cards as answered. This removes +incident defect 4 and the rebuild half of defect 1. + +Lane: `plan/concurrent-approvals` (PR #5382). Note that `protocol.ts`, `persist.ts`, and +`api/oss/src/core/sessions/interactions/dtos.py` are also touched by JP's lanes in the +other stack; the regions edited here are identical on both stacks (research.md R8), but +follow the isolation commit procedure strictly. + +### 3a. Runner: the new event and its emission + +Files: `services/runner/src/protocol.ts`, `services/runner/src/sessions/persist.ts`, +`services/runner/src/engines/sandbox_agent/run-turn.ts`, +`services/runner/src/engines/sandbox_agent/acp-interactions.ts`, +`services/runner/src/sessions/interactions.ts`. + +Contract: + +- Add to the `AgentEvent` union (`protocol.ts`, next to `interaction_request` at + lines 362-367): + + ```ts + // The durable answer half of an interaction_request, emitted when the runner forwards + // a human decision to the harness. `id` equals the matching request's id. Hydration + // overlays it so a rebuilt conversation shows the gate as answered. + | { + type: "interaction_response"; + id: string; + kind: "user_approval"; + payload?: unknown; + } + ``` + + Payload for `user_approval`: `{toolCallId: string, approved: boolean}`. No actor field + (audit actor identity is queued work; the record row's credential-derived metadata is the + interim signal). Also add `interaction_response` to the documented known-types list in + `sdks/python/agenta/sdk/agents/wire_models.py:381` (a docstring, not enforcement; the + Python event model is an open union and needs no code change). +- Emit it at the ONE convergence point both answer paths share. In `run-turn.ts`, + `resolveInteractionToken` (lines 287-296) is called by the warm-resume loop (line 479) + and wired as `onResolveInteraction` into the gate handler (line 323), which fires it + from `replyPermission`/`replyClientTool` after a successful harness reply + (`acp-interactions.ts:223-226, 251, 271`). Extend the signature to carry the verdict and + the tool-call id: `resolveInteractionToken(token, {approved, toolCallId})`. Callers: + the resume loop derives `approved` from `decision.reply === "once"` and has + `decision.toolCallId`; `replyPermission` has `decision` and `toolCallId`; + `replyClientTool` resolves client-tool interactions, which have no allow/deny verdict, + so it passes no verdict and 3a emits nothing for it (client tools get their answer + recorded as the tool result itself). Inside the function, alongside the existing + `resolveInteraction` POST, call `run.emitEvent({type: "interaction_response", id: token, + kind: "user_approval", payload: {toolCallId, approved}})`. `run` is in scope in + `runTurn`; pass it in or close over it. +- Give the event a stable record id so a retried resume upserts one row: extend the + stable-id branch in `persist.ts` (lines 297-313) to include `interaction_response` + (keyed by the event's `id`, which is the interaction token, and the record type, via + the existing `stableRecordId`). +- The live stream also carries the event; the Python Vercel egress ignores unknown types + by construction (no `else` in its ladder) and the web ignores unknown record types + outside the hydration switch, so no egress change is needed. + +### 3b. Backend: the verdict on the interaction row + +Files: `api/oss/src/core/sessions/interactions/dtos.py`, +`api/oss/src/apis/fastapi/sessions/models.py`, +`api/oss/src/apis/fastapi/sessions/router.py`, +`api/oss/src/dbs/postgres/sessions/interactions/dao.py`, plus the interactions service and +interface files if they type the transition. + +Contract: + +- `SessionInteractionTransition` (`dtos.py:66-70`) gains + `resolution: Optional[Dict[str, Any]] = None`. Field classification: `resolution` is + data (the answer content), `status` stays lifecycle metadata, `token`/`session_id` stay + protocol context. The transition request model in `models.py` gains the same field, and + `transition_interaction` (`router.py:625-655`) passes it through. +- The DAO's transition UPDATE (`dao.py:91-119`) additionally writes the verdict into the + row's `data.resolution` when the transition carries one, without clobbering the rest of + `data` (use a JSONB set on the `resolution` key, or a read-modify-write inside the same + guard; the guard `status IN ('pending','responded')` is unchanged). A transition without + `resolution` behaves exactly as today. +- Resolution payload written by the runner: + `{"verdict": "approved" | "denied", "tool_call_id": }`. Full words, no + abbreviations. +- Runner side: `resolveInteraction` (`services/runner/src/sessions/interactions.ts:97-119`) + gains an optional `resolution` argument and includes it in the POST body when present. + `resolveInteractionToken` (3a) passes it. + +Acceptance: after an approval, `GET /sessions/interactions/{id}` returns the row with +`status: "resolved"` and `data.resolution == {"verdict": "approved", "tool_call_id": ...}`. +A transition without resolution leaves `data` untouched. Run the API formatters and tests. + +### 3c. Frontend: the hydration overlay + +Files: `web/oss/src/components/AgentChatSlice/assets/transcriptToMessages.ts`, plus a unit +test colocated per the web testing conventions, and optionally +`web/oss/src/components/AgentChatSlice/components/Inspector/timeline.ts` (add +`interaction_response` to `TimelineEventType`, `EVENT_META`, and `KNOWN` so the Inspector +shows it as a first-class row instead of an "other" chip). + +Contract: + +- Add `case "interaction_response"` to the switch (line 87). Resolve the target tool part + with the same id-resolution the request case uses (lines 151-153): prefer + `payload.toolCallId`, fall back to a part whose `approval.id` equals the event's `id`. + When the part's `state` is `"approval-requested"`, set `state = "approval-responded"` + and `approval = {id: , approved: }`. When the part has + already advanced past the request state (for example the executed call's + `output-available` overwrote it), do nothing; execution states supersede answer states. +- The produced shape must be byte-identical in structure to what the live + `addToolApprovalResponse` produces, so the dock, the activity list, and the resume + predicate treat rebuilt and live states identically (research.md R2). +- The rebuilt answered card must not re-trigger any dispatch: dispatch fires only from live + clicks (the `liveGateInteractionRef` marker in `AgentConversation.tsx:1034-1040` and the + restored-tail guard at lines 1011-1016 stay authoritative). Add nothing that sends + network requests from hydration. + +Acceptance: a unit test feeds `transcriptToMessages` a record list containing a +`tool_call`, its `interaction_request`, and an `interaction_response` with +`approved: true`, and asserts the resulting part is `approval-responded` with +`approval.approved === true`; the negative case (no response record) stays +`approval-requested`; a response arriving after the call's `tool_result` leaves the +executed state untouched. Run `pnpm lint-fix` in `web` and the affected unit tests. + +## Step 4: per-card dispatch and partial answer sets + +Purpose: after this step, one click sends one answer. The frontend dispatches an approval +the moment the user answers a card, without waiting for sibling cards, and the runner +accepts a resume that answers a subset of the parked gates: it answers those, streams +their real results, and parks again on the rest. This removes incident defect 1's +dispatch half (the rebuild half fell to step 3). + +Lane: `plan/concurrent-approvals` (PR #5382). + +### 4a. Runner: accept a partial answer set + +Files: `services/runner/src/server.ts`, +`services/runner/src/engines/sandbox_agent/run-turn.ts`, +`services/runner/src/engines/sandbox_agent/runtime-contracts.ts`, +`services/runner/tests/unit/session-keepalive-approval.test.ts`. + +Contract (the mechanics are in research.md R4): + +- Dispatch (`server.ts:663-740`): building `resumeDecisions`, a gate without a matching + decision is no longer a mismatch. Split the parked set into `answered` (decisions built + as today) and `carriedForward` (the untouched `ParkedApproval` records). Resume live + when `answered` is non-empty; keep every other mismatch (unrecognized gate type, edited + history, expired mount) and the zero-answers case exactly as today (evict to cold). + Pass both sets to the engine: + `opts.resume = {decisions, carriedForward}` (extend `RunTurnOptions` in + `runtime-contracts.ts:141-167`). +- `inBandAnswerTokens` (`server.ts:849-872`): spare from the stale-interaction sweep the + tokens of the ANSWERED gates only. Carried-forward gates stay pending on the + interactions plane, and they now survive because the resume re-parks them (the comment + block there describing the all-or-cold rule must be rewritten to match the new + behavior). +- Resume turn (`run-turn.ts`): the turn-start clear (lines 88-95) currently empties + `env.parkedApprovals`. On a resume with carried-forward gates, re-seed the map with them + after the clear, restore `env.approvalGateCount` to the map's size, and call + `pause.markPausedToolCall(gate.toolCallId)` for each so their frames stay suppressed + (the harness will not re-raise these gates; they are still pending inside the live + process). After the answer loop (lines 448-483) finishes and carried-forward gates + remain, arm the pause (`pause.pause()`): the turn then ends parked once the answered + calls' results have landed (the step 2 bounded wait governs that), and the existing + re-park path (`reparkOrEvict` via `approvalToPark`, `server.ts:423-451, 517-559`) + re-parks in `awaiting_approval` with a fresh approval TTL, and `watchParkedPrompt` + re-attaches to the shared prompt promise (safe: identity-checked and idempotent). +- A new gate raised DURING the resume (the incident's exact shape: Pi serializes confirms, + so gate 2 surfaces only after gate 1 is answered) needs no new handling: it fires + `onUserApprovalGate` normally, joins `env.parkedApprovals` next to the carried-forward + records, and pauses the turn itself. +- Verify the history fingerprint is insensitive to which approval envelopes ride the + request (research.md open risk 1). Read `historyFingerprint` in + `session-identity.ts` first; if it hashes tool-result blocks, adjust the park-side + expected fingerprint so a partial answer still matches, and pin it in the dispatch + test. + +### 4b. Frontend: dispatch per card + +Files: `web/packages/agenta-playground/src/state/execution/agentApprovalResume.ts`, +`web/packages/agenta-playground/tests/unit/agentApprovalResume.test.ts`, and +`web/oss/src/components/AgentChatSlice/AgentConversation.tsx` only if the guard wiring +needs it. + +Contract: + +- `agentShouldResumeAfterApproval` (`agentApprovalResume.ts:131-165`) currently requires + EVERY tool part settled (`allSettled`, line 163). Replace that final condition: resume + when there is at least one freshly answered approval (the existing + "last freshly-resolved parked interaction" detection at lines 146-150) that no + `step-start` part follows (the existing already-resumed guard at lines 158-161), even + if sibling cards are still `approval-requested`. Client-tool results keep their + existing all-settled requirement if relaxing it is unsafe for them; this project's + scope is approval cards. +- The AI SDK evaluates this predicate on message updates and does not send while a stream + is in flight, so an answer clicked during a streaming resume dispatches when that + stream finishes. State this in a comment; it is the concurrency contract. +- The restored-tail guard stays: a rebuilt conversation whose answers came from + `interaction_response` records must not auto-fire (step 3c). Only a live click flips + `liveGateInteractionRef` and produces a "freshly" answered part. +- "Approve all" in the dock (`ApprovalDock.tsx:160-164`) loops the responses + synchronously; the predicate then fires once with every card answered, which the runner + handles as a full set. No dock change. + +### Acceptance criteria for step 4 + +- Dispatch-seam test (rewrite `session-keepalive-approval.test.ts:579`): a two-gate park + answered one card resumes live (never cold), answers exactly that gate's + `permissionId`, re-parks in `awaiting_approval` with the second gate carried forward, + and a second request answering the second card resumes live again and completes the + turn. A request answering zero gates still evicts to cold. +- Engine-seam test: a resume with one answered and one carried-forward gate marks the + carried-forward call paused (its frames suppressed), ends parked, and never settles it + with any sentinel. +- Frontend unit test: the predicate fires with one answered card and one pending card; + does not fire when a `step-start` follows the answer; does not fire on a rebuilt + conversation with no live click. +- Test commands: `cd services/runner && pnpm test && pnpm run typecheck`; web package + tests per `web/AGENTS.md` for `agenta-playground`; `pnpm lint-fix` in `web`. + +## Step 5: the incident regression test, then live QA + +Purpose: pin the exact incident shape end to end, then verify the deployed behavior by +hand. + +Lane: `plan/concurrent-approvals` (PR #5382) for the tests; QA is not a code change. + +### 5a. The incident regression test + +File: `services/runner/tests/unit/session-keepalive-approval.test.ts` (engine seam, the +pausable fake harness of the describe block at line 1260, extended), or a new sibling +file if it grows large. + +Script the exact db58551b shape: + +1. Turn 1: the fake harness announces two ask-policy calls `tool-a` and `tool-b` + (`sessionUpdate: "tool_call"` events), raises the permission request for `tool-a` + ONLY, and hangs the prompt. Expect: one `interaction_request` for `tool-a`; `tool-b` + is settled with `TOOL_NOT_EXECUTED_PAUSED` (announced, never gated, never started; it + holds no approval, so the sweep may settle it); the turn parks with one gate. +2. If the fake emits a cancellation-closure `completed` frame for `tool-b` during the + park (add this to the script), expect the deferred sentinel, NOT a success record. + This pins defect 3. +3. Resume 1 (approve `tool-a`): the fake answers the `respondPermission`, emits an + `in_progress` frame for `tool-a`, then raises the permission request for `tool-b` + (the gate that surfaces DURING the warm resume), then emits `tool-a`'s real + `completed` frame with distinctive output. Expect: `tool-a`'s real result recorded + exactly once, no sentinel ever attached to it (defect 2 pinned); an + `interaction_request` card for `tool-b`; an `interaction_response` event for `tool-a` + with `approved: true` (step 3 pinned); the turn re-parks on `tool-b`. +4. Resume 2 (approve `tool-b`): the fake answers, emits `tool-b`'s real `completed` + frame, and resolves the held prompt. Expect: `tool-b`'s real result exactly once, its + `interaction_response`, and a completed turn. +5. Global assertions: each permission id received exactly one `respondPermission` (the + "both side effects exactly once" proxy at this seam), and the final event log contains + exactly one real result per call and no success record for any never-started state. + +### 5b. The records and hydration regression test + +As specified in step 3c's acceptance criteria, plus one round-trip-shaped case: build the +record list in the exact order the runner persists during the incident shape (user +message, tool_call a, interaction_request a, tool_result b deferred, interaction_response +a, tool_result a real, interaction_request b) and assert the rebuilt messages show call a +executed with its real output, call b's card pending, and no card flipped back to +waiting. This is the state-rebuild half of the incident. + +### 5c. Live QA + +Run the script in `qa.md` against the dev stack after all lanes are deployed, record the +MP4, and post it as a PR comment on #5382. Then re-run the release-gate approval cells +listed there. + +## Landing order and dependencies + +The steps are ordered by user-facing severity and by dependency: + +1. Step 1 is independent of everything else and unblocks JP's PR stack; land first. +2. Step 2 is self-contained in the runner and makes results truthful; step 4's partial + resume DEPENDS on its bounded wait for the "answered call finishes before the re-park" + behavior, so land 2 before 4. +3. Step 3 is independent of 2 (different regions) and is what makes step 4's frontend + guard sound after a rebuild; land 3 before 4. +4. Step 4 last among the code steps, then step 5's tests can pin the full behavior (its + sub-assertions on sentinels and answer events need 2 and 3 in place). + +Each step is a separate commit (or small commit series) on its lane so review can bisect. diff --git a/docs/design/agent-workflows/projects/approvals-incident-fixes/qa.md b/docs/design/agent-workflows/projects/approvals-incident-fixes/qa.md new file mode 100644 index 0000000000..c6fd0f4ab3 --- /dev/null +++ b/docs/design/agent-workflows/projects/approvals-incident-fixes/qa.md @@ -0,0 +1,96 @@ +# Live QA script + +This script reproduces the shape of incident session db58551b on the dev stack and states +the correct behavior at every point. Run it after all five plan steps are deployed. The +run MUST produce a watchable MP4 screen recording (ffmpeg is available in `~/.local/bin` +on the dev box workflow), uploaded as a comment on PR #5382, and the recording is listed +first in the QA report. + +## Setup + +1. Deploy the branch stack to the local EE dev deployment (see the `debug-local-deployment` + skill for the box, ports, and login; see the root `AGENTS.md` for the + `load-env` + `run.sh --ee --dev` pairing). Confirm the runner container runs the + `plan/concurrent-approvals` code and the API runs the `sessions-rebase/backend` code. +2. If step 1c's wipe has not been run yet, run it now (plan.md step 1c), before any QA + turn, so turn indexes start clean. +3. Open the playground's agent chat with the Pi harness (`pi_core`), local sandbox, and a + permission policy that gates `Bash` with `ask` (the default ask policy used in the + incident). +4. Start the screen recording before the first prompt. + +## Scenario A: two parallel gated writes (the incident shape) + +Prompt: + +> Append the line "hello from QA" to agent-files/README.md and to agent-files/NOTES.md, +> as two separate Bash commands issued in parallel in the same turn. + +Walk through and check each point: + +1. **First card appears.** The model issues two Bash calls; Pi serializes confirms, so one + approval card appears first. Correct behavior: the second call shows as a pending tool + part or a deferred sibling, and NOTHING shows a successful "(no output)" result for a + command that has not run (defect 3). Open the Inspector's record timeline and confirm + the second call has no success `tool_result` row. +2. **Approve card 1.** Correct behavior: the approved command executes, its REAL output + (or a clean completed state) appears on the card, and the card stays in its approved + or executed state permanently. It must never flip back to "waiting for approval" + (defect 4/1), and it must never show the text "DEFERRED_NOT_EXECUTED" (defect 2). +3. **Second card appears** during or right after the first command's execution (it + surfaces on the warm resume). Correct behavior: card 1's state is unaffected. +4. **Approve card 2 and do nothing else.** This is the click that died in the incident. + Correct behavior: the approval dispatches on its own (watch the network tab for the + message request; no extra text message is needed), the second command executes, and + the assistant completes the turn. +5. **Verify the files.** In the session's workspace, each file contains the appended line + EXACTLY once. Two lines in one file or a missing line fails the run. +6. **Verify the records.** In the Inspector: two `interaction_request` rows and two + `interaction_response` rows (one per gate), and one truthful `tool_result` per call. +7. **Verify the turns.** On the dev database: + `SELECT turn_index, harness_kind, created_at FROM session_turns WHERE session_id = '' ORDER BY turn_index;` + Indexes are 0, 1, 2, ... with no gaps and no duplicates, and the API access log shows + no `POST /api/sessions/turns/ ... 500` lines for the session. + +## Scenario B: rebuild while a gate is pending + +1. Repeat the Scenario A prompt in a fresh session. Approve card 1, wait for card 2 to + appear, then RELOAD the page before answering it. +2. Correct behavior after reload: card 1 renders as answered/executed (hydrated from its + `interaction_response` record); card 2 renders as pending. No answered card is + resurrected as waiting. +3. Approve card 2 after the reload. Correct behavior: the approval dispatches and the turn + completes, exactly as without the reload. This was impossible before the fix (the + all-settled condition could never hold after a rebuild). +4. Open the same session in a second browser window and confirm the same rendering. + +## Scenario C: partial answers, one at a time + +1. To get two cards genuinely outstanding at once, use the Claude harness (`claude`) with + two ask-gated calls in one turn (Claude raises concurrent permission requests; Pi + serializes them, issue #5391). Prompt for two parallel gated writes as in Scenario A. +2. Answer ONLY the first card. Correct behavior: the answer dispatches immediately, the + first command executes and reports truthfully, and the second card remains pending + (the session re-parks; it does not degrade to a cold restart and does not cancel the + second gate). +3. Answer the second card. Correct behavior: the turn completes; both side effects exactly + once. + +## Known limitation to note in the report (not a gate) + +Sending a NEW text message while a card is pending still routes down the approval-resume +or eviction path and can consume the message into the stale task (incident defect 6). +Real cancel-then-restart on new user text is explicitly out of scope here (see +context.md); note the observed behavior in the report rather than failing the run on it. + +## Release-gate cells to re-run + +After the scenarios pass, run the `agent-release-gate` skill against the same deployment +and re-run at least: + +- the human-approval park and resume cells for `pi_core` on the local sandbox, +- the human-approval cells for `claude` on the local sandbox, +- the deny-path cell (a rejected gate renders as a decline, not an error), +- one full smoke cell per harness to catch regressions outside the approval path. + +Attach the gate output to the PR comment along with the MP4. diff --git a/docs/design/agent-workflows/projects/approvals-incident-fixes/research.md b/docs/design/agent-workflows/projects/approvals-incident-fixes/research.md new file mode 100644 index 0000000000..b1b5a5b0dc --- /dev/null +++ b/docs/design/agent-workflows/projects/approvals-incident-fixes/research.md @@ -0,0 +1,484 @@ +# Research findings (R1 through R8) + +Every claim below was verified against the working tree on 2026-07-19 unless marked +otherwise. Paths are repo-relative. Line numbers refer to the current workspace working +tree, which has both PR stacks applied; where a file differs on an individual branch, the +difference is called out. Open risks are collected at the end and also flagged inline. + +## R1. The records ingest path for a new event type + +Question: can a new `interaction_response` event flow from the runner into the durable +record store and back out to the frontend without any hop rejecting it? + +Answer: yes, with exactly two required code changes (one runner type, one frontend switch +case) and one recommended runner change (a stable record id). No API change is needed. + +The path, hop by hop: + +1. **Runner event type.** The runner's event union `AgentEvent` + (`services/runner/src/protocol.ts:325-383`) has an `interaction_request` member + (lines 362-367) and no answer member. A new member must be added here; TypeScript narrows + on `type`, so nothing else in the runner needs it. The cross-language wire contract does + NOT pin event union members: the golden fixtures pin request and result top-level keys + only (`services/runner/tests/unit/wire-contract.test.ts`), and the Python mirror's event + model is deliberately open ("keeps the whole event verbatim and drops a typeless event", + `sdks/python/agenta/sdk/agents/wire_models.py:370-384`). The docstring at + `wire_models.py:381` lists the known types for readers; add `interaction_response` to + that list when implementing (documentation only, not enforcement). +2. **Runner emission choke point.** Events emitted via `run.emitEvent` go through the single + `record()` choke point (`services/runner/src/tracing/otel.ts:1141-1150`), which appends to + the batch log and forwards to the live sink. The sink on session-owned runs is the + persisting emitter. +3. **Runner persistence.** `buildPersistingEmitter` + (`services/runner/src/sessions/persist.ts:160-352`) posts every event to + `POST /sessions/records/ingest` with `record_type: event.type` and the whole event as + `attributes` (`persist.ts:59-73`). An unknown type takes the generic persist branch at + `persist.ts:316-326` untouched. One change is recommended: the stable-id branch at + `persist.ts:297-313` gives `tool_result` and `interaction_request` a deterministic uuid5 + record id (`stableRecordId`, `services/runner/src/sessions/record-id.ts:40-47`, keyed on + session id, event id, and record type) so a re-sent event upserts one row instead of + appending duplicates. `interaction_response` should be added to that branch so a retried + resume cannot double-record an answer. Note the id is keyed by record type, so a request + and its answer land on two distinct rows even though they share the event id. +4. **Live stream egress (not on the persistence path, but the same event reaches it).** The + Python SDK's Vercel egress projects known event types through an `elif` ladder with no + `else` branch (`sdks/python/agenta/sdk/agents/adapters/vercel/stream.py:156-312` and + `436-592`); an unknown type is silently skipped. So emitting `interaction_response` on + the live stream is harmless: the live UI already knows the answer (the user just clicked + it) and needs no projection. +5. **API ingest.** `POST /sessions/records/ingest` + (`api/oss/src/apis/fastapi/sessions/router.py:506-536`) validates + `SessionRecordIngestRequest` (`api/oss/src/apis/fastapi/sessions/models.py:198-211`), + where `record_type` is `Optional[str]` and `attributes` is a free dict. It publishes to a + Redis stream (`api/oss/src/core/sessions/records/streaming.py:51-97`, XADD at line 89); + the records worker consumes it and calls `RecordsService.append_many` + (`api/oss/src/tasks/asyncio/sessions/records_worker.py:149`). The DAO upserts on the + composite primary key `(project_id, record_id)` + (`api/oss/src/dbs/postgres/sessions/records/dao.py:86-97`); a client-supplied stable id + is honored, otherwise a uuid4 is minted + (`api/oss/src/dbs/postgres/sessions/records/mappings.py:17-28`). There is no enum, + Literal, or DB constraint on the event type anywhere on this path: `record_type` is a + nullable String column (`api/oss/src/dbs/postgres/sessions/records/dbas.py:55-58`) and + `attributes` is unconstrained JSONB (`dbas.py:64-67`). The rows live in the `records` + table in the tracing database (`api/oss/src/dbs/postgres/sessions/records/dbes.py:15`). +6. **API query.** The frontend hydrates from `POST /sessions/records/query` + (`router.py:464-485`), which filters by project and session and orders by + `created_at ASC, record_index ASC` (`records/dao.py:99-116`). No type filter. +7. **Frontend fetch and validation.** `querySessionRecords` + (`web/packages/agenta-entities/src/session/api/api.ts:56-80`) calls the generated Fern + client and validates with `sessionRecordsQueryResponseSchema` + (`web/packages/agenta-entities/src/session/core/schema.ts:19-45`), where + `record_type: z.string().nullish()` and `attributes` is an open record. An unknown event + type passes validation unchanged. +8. **Frontend hydration.** `loadSession` + (`web/oss/src/components/AgentChatSlice/assets/loadSession.ts:29-35`) passes rows + straight to `transcriptToMessages` + (`web/oss/src/components/AgentChatSlice/assets/transcriptToMessages.ts`), whose + `switch (type)` (line 87) has a silent `default: return` (lines 208-210). **This is the + one place an `interaction_response` record is dropped today and the one required frontend + change.** + +Sweep of every other frontend narrowing over event types (none blocks the new event): + +- `web/oss/src/components/AgentChatSlice/components/Inspector/timeline.ts:10-52`: a `KNOWN` + allowlist maps unknown types to an `"other"` chip. Cosmetic only; adding + `interaction_response` to `TimelineEventType`, `EVENT_META`, and `KNOWN` is optional. +- `Inspector/EventRow.tsx`, `Inspector/lenses/TimelineLens.tsx:26-27,114`, + `Inspector/lenses/ContextLens.tsx:50-56`: if-chains that ignore unmatched types. No crash. +- `web/oss/src/components/AgentChatSlice/assets/AgentChatTransport.ts:74`: normalizes batch + response blocks, not session records; unknown parts degrade to text. Not on this path. +- `web/packages/agenta-playground/`: zero matches for record event type literals. The + playground operates on assembled message parts, not on record events. + +No location was found where an unknown event type causes an error on any hop. + +## R2. The frontend hydration overlay design + +Question: where must answered state be injected so a rebuilt approval card renders as +answered, and how do live-session local state and rebuilt state converge? + +How the two states work today: + +- **Live state** is the AI SDK's `useChat` message array. `addToolApprovalResponse` + (destructured at `web/oss/src/components/AgentChatSlice/AgentConversation.tsx:575`, + wrapped at lines 1034-1040) mutates the matching tool part in memory: `state` goes from + `"approval-requested"` to `"approval-responded"` and `approval.approved` is set. Nothing + persists this. The wrapper also sets `liveGateInteractionRef.current = true`, the marker + that distinguishes a live click from restored state. +- **Rebuilt state** comes from `transcriptToMessages`, which constructs the card from + `interaction_request` only (`transcriptToMessages.ts:144-176`): it finds or synthesizes + the tool part for the gated call and stamps `state = "approval-requested"` and + `approval = {id}` (lines 171-174). There are no answer records, so every rebuilt card is + pending. This is the root of incident defect 4. + +The decided shape: hydration must produce the SAME part shape the live path produces, so the +two states converge on identical data and every downstream consumer (dock, activity list, +resume predicate) works unchanged. + +- The new event: `{type: "interaction_response", id: , kind: + "user_approval", payload: {toolCallId, approved: boolean}}`. The `id` equals the matching + `interaction_request`'s `id` (the interaction token minted at + `services/runner/src/engines/sandbox_agent/acp-interactions.ts:587-589`), which is the + linkage key. Field classification per the design-interfaces method: `approved` is the + data (the verdict itself); `toolCallId` and `id` are protocol context (correlation); + the record envelope's timestamp and credential-derived author are metadata and ride the + record row, not the payload. No actor field is added; recording who approved is part of + the queued audit-hardening work. +- The hydration change: a new `case "interaction_response"` in `transcriptToMessages.ts` + that resolves the same tool part (by `payload.toolCallId`, falling back to matching + `approval.id`) and, when the part is in `"approval-requested"`, sets + `state = "approval-responded"` and `approval = {id, approved}`. Exactly the shape + `addToolApprovalResponse` produces. +- Convergence rule: answered-by-record and answered-by-click are indistinguishable by shape. + The dispatch trigger is the live click (the `liveGateInteractionRef` marker and the + restored-tail guard at `AgentConversation.tsx:1011-1016`), never the state shape, so a + rebuilt already-answered turn cannot re-fire a resume. A card that was answered locally + but whose answer never reached the runner has no `interaction_response` record, so a + rebuild correctly shows it pending again and the user can answer again; that is the + correct recovery, since the decision existed only in lost browser memory. + +The card state literals are inline AI SDK `ToolUIPart["state"]` strings, not a local union: +`"approval-requested"` is set at `transcriptToMessages.ts:172` and consumed in +`ApprovalDock.tsx:33-45`, `ToolActivity.tsx`, and `agentApprovalResume.ts`; +`"approval-responded"` is set by the SDK and consumed in the same files. + +## R3. The interactions API verdict field + +Question: how does the allow/deny verdict get onto the interaction row? + +Today's state, verified: + +- `SessionInteractionData` (`api/oss/src/core/sessions/interactions/dtos.py:24-28`) already + has `resolution: Optional[Dict[str, Any]]` (line 28). A repo grep shows nothing ever + writes it. +- `SessionInteractionTransition` (`dtos.py:66-70`) carries only `project_id`, `session_id`, + `token`, `status`. The transition endpoint (`router.py:625-655`) passes it to the DAO, + whose UPDATE sets only `status` and `updated_at`, guarded on + `status IN ('pending','responded')` + (`api/oss/src/dbs/postgres/sessions/interactions/dao.py:91-119`, SET at 108-111). +- The status enum (`dtos.py:16-21`) is lifecycle only: `pending`, `responded`, `resolved`, + `cancelled`. The comment at line 17 states the verdict does not belong in it. That + matches the design-interfaces classification: `status` is lifecycle metadata, + `resolution` is data (the answer content), `token` and `session_id` are protocol context. +- The `data` column is JSONB and round-trips through + `SessionInteractionData.model_validate(dbe.data)` + (`.../interactions/mappings.py:34-36, 65`), so storing a verdict inside + `data.resolution` needs no migration. + +The specified addition (details in plan.md step 3): `SessionInteractionTransition` gains +`resolution: Optional[Dict[str, Any]]`; the transition endpoint and DAO write it into the +row's `data.resolution` when present; the runner's `resolveInteraction` +(`services/runner/src/sessions/interactions.ts:97-119`) gains the verdict payload +`{verdict: "approved" | "denied", tool_call_id}` and its callers pass the decision through +(the warm-resume loop at `services/runner/src/engines/sandbox_agent/run-turn.ts:479` and +the stored-decision path via `onResolveInteraction`, +`acp-interactions.ts:223-226` and `228-252`, where `replyPermission` already holds the +decision). + +## R4. The partial-resume change + +Question: what exactly must change so a resume request that answers only some parked gates +is accepted? + +Today's all-or-cold rule, verified: + +- The dispatch's approval branch (`services/runner/src/server.ts:663-740`) builds one resume + decision per parked gate from the request + (`approvalDecisionForToolCall`, + `services/runner/src/engines/sandbox_agent/session-identity.ts:274-291`, strict tool-call + id match on the `{approved}` envelope). Any gate without a matching decision sets + `mismatch = "no-matching-approval"` (line 707) and the whole parked session is evicted to + cold (lines 730-740). +- The stale-interaction sweep helper mirrors the same rule + (`inBandAnswerTokens`, `server.ts:849-872`). +- The warm-resume turn answers every decision in a loop + (`run-turn.ts:439-483`): seeds the trace with the parked call, grants execution for an + approve, marks a deny, calls `respondPermission` per gate, and resolves each interaction + row. All decisions share one held prompt promise (one prompt per turn). +- Per-turn park bookkeeping resets at every turn start (`run-turn.ts:88-95` clears + `env.parkedApprovals`), and the re-park only happens when the pause controller fires again + this turn (`run-turn.ts:516-520`, `server.ts:494-505` and `529-545` via `approvalToPark`, + `server.ts:423-451`). + +What "accept a partial answer set" must mean, given those mechanics: + +1. **Dispatch**: a request that answers a non-empty subset of the parked gates resumes live + with that subset. A request that answers none keeps today's behavior (evict to cold); + changing that is the out-of-scope defect 6 work. +2. **Carried-forward gates**: the unanswered gates' `ParkedApproval` records + (`runtime-contracts.ts:112-127`) must survive into the resume turn. The turn-start clear + must not drop them, because the harness will NOT re-raise those permission requests: they + are still pending inside the live process. The resume turn must re-mark their tool-call + ids as paused (`pause.markPausedToolCall`, + `services/runner/src/engines/sandbox_agent/pause.ts:54-57`) so their frames stay + suppressed, and must arm the pause so the turn ends parked again after the answered + calls' results land (the step 2 bounded wait governs when). +3. **Response stream**: the resume turn streams the answered calls' seeded `tool_call` + frames, their real execution results, and then ends with `stopReason: "paused"`; the + still-parked cards stay pending on the client. No eviction to cold happens. +4. **Re-park**: `reparkOrEvict` re-parks with state `awaiting_approval` because + `env.parkedApprovals` is non-empty and the pause is active; the approval TTL re-arms + fresh (default 5 minutes, `session-pool.ts` arms it in `park`/`repark`, + `session-pool.ts:147-232`), and `watchParkedPrompt` (`server.ts:461-478`) re-attaches to + the same held prompt promise; its catch-based eviction is identity-checked and idempotent, + so re-attachment is safe. +5. **Races**: `checkoutApproval` removes the entry from the local pool + (`session-pool.ts:126-134`), so a second answer arriving while a resume is in flight + misses the pool and runs cold; the cold decision-map path re-raises unanswered gates and + consumes carried envelopes, which is the safe degradation (pinned by the existing test + "a second identical approval while the first resume is in flight", + `tests/unit/session-keepalive-approval.test.ts:930`). The frontend dispatch (step 4) + only fires when the chat transport is idle, so this race needs a second browser to occur. + +One existing test pins the CURRENT all-or-cold behavior and must be rewritten by this +change: "keeps a partly-answered two-gate turn paused (only one card answered -> cold)" +(`tests/unit/session-keepalive-approval.test.ts:579`). + +**Open question resolved during research**: the history-fingerprint check +(`server.ts:721-728`) also gates the resume. Today's full-answer resumes pass it, and a +partial-answer request differs from a full-answer request only in which `{approved}` +tool-result envelopes ride the tail. The fingerprint folds emitted tool-call ids +(`session-identity.ts:240-251`); whether it also hashes tool-result blocks was NOT +conclusively verified from `historyFingerprint`'s implementation. This is flagged as an +open risk: the implementer must confirm (or make) the fingerprint insensitive to the +presence or absence of approval envelopes, or partial resumes will spuriously evict to +cold. The dispatch-seam unit test in step 4 pins this. + +## R5. The sweep replacement + +Question: which open tool calls may the post-pause sweep still settle, which must it never +touch, and how is a cancellation-closure frame for a never-started call recorded? + +The machinery, verified: + +- The sentinel: `TOOL_NOT_EXECUTED_PAUSED` (`services/runner/src/tracing/otel.ts:66`) is + `"DEFERRED_NOT_EXECUTED: paused for another approval; retry the same call if still + required."` The prefix is machine-read in two places: the responder keeps deferred + results out of the client-output store (`responder.ts:491-496`, consumed at + `responder.ts:398-401`), and the web renders deferred siblings distinctly. +- The sweep: `settleOpenToolCalls` (`otel.ts:1479-1492`) closes every tracked open call not + excluded by the predicate and records `tool_result {isError: true}` with the sentinel. + Two call sites, both excluding ONLY paused gates (`pause.isPausedToolCall`): the in-band + re-sweep on every non-suppressed frame while paused (`run-turn.ts:232-237`) and the + post-drain sweep (`run-turn.ts:505-511`). +- The result mapping: `maybeCloseTool` (`otel.ts:1446-1473`) records ANY + `completed`/`failed` status frame as the call's result, with + `isError: status === "failed"`. It has no notion of whether execution ever started. This + is how the incident's never-started call got a successful `"(no output)"` result. +- Frame suppression during a pause (`shouldSuppressPausedToolCallUpdate`, + `services/runner/src/engines/sandbox_agent/runtime-policy.ts:31-60`): paused-gate frames + are dropped (line 44), `failed` frames for any other call are dropped as managed-cancel + artifacts (lines 56-58), `completed` frames pass deliberately so a legitimately finishing + auto-allowed sibling keeps its real result (comment at lines 48-55). +- Late events: once the turn's sink is cleared, between-turn events are logged and dropped + (`services/runner/src/engines/sandbox_agent/session-events.ts:31-35`). This is what + destroyed the incident's approved call's real result: it landed after the park. +- The drain: `pause.waitForEventDrain` is one `setImmediate` after the destroy callback + settles (`pause.ts:27-47`), so "post-drain" is a single event-loop tick, not a bounded + wait for results. +- The per-turn approval ledger that exists today is name-and-args keyed + (`ApprovedExecutionGrants`, `responder.ts:85-105`), built for the relay execution guard, + not id-keyed sweep protection. The warm-resume loop knows each approved gate's tool-call + id (`run-turn.ts:448-483`), and the in-turn allow path knows it too + (`replyPermission`, `acp-interactions.ts:228-252`, which receives the `toolCallId`). + +The specified contract (mechanics in plan.md step 2): + +- **The sweep may still settle**: a call that was announced but never started and holds no + approval this turn; concretely, a call that is neither a paused gate nor in the new + approved-or-allowed id set. For such a call the deferred sentinel is correct: it never + executed, and inviting a retry is safe. +- **The sweep must never touch**: a call whose gate was answered allow this turn (warm + resume or in-turn), or whose policy auto-allowed it. Terminalization of a paused turn + must wait for those calls' own terminal frames, bounded per call by the existing + per-tool-call time limit (the run-limits deadlines are retired at pause by + `runLimits.notePaused()`, `run-turn.ts:188`, so this wait needs its own timer using the + same configured value from `run-limits.ts`). If the bound expires, the call is settled + with a NEW sentinel that must NOT invite a retry (execution started; a retry could double + a side effect), and that sentinel must also be excluded from the client-output store the + way the deferred one is (`responder.ts:398-401`). +- **Cancellation-closure detection**: while the pause is active, a `completed` frame for a + call that required a gate (effective permission `ask` or `deny`) that was never answered + allow CANNOT be a genuine completion, because both harness paths fail closed (Pi's + in-process confirm allows execution only on an explicit `true`, + `services/runner/src/extensions/agenta.ts`; Claude asks before executing). Such a frame + is a cancellation-closure artifact and must be recorded as the deferred sentinel, not as + success. A `completed` frame for an allow-policy call, or for a call in the + approved-or-allowed set, keeps its real result. Buffering `completed` frames for + unclassified calls until the drain settles (as the incident report sketches) makes the + classification race-free against a gate that arrives in the same tick. +- **The model-transcript requirement**: every tool call the model ever sees must eventually + carry SOME result. The design satisfies it on all three continuation paths. On a warm + resume and on a cold native continue, the harness owns its own transcript and the + runner's records never feed the model, so nothing is lost by holding the runner-side + record open briefly. On the replay fallback (the only path that rebuilds the prompt from + our records), every announced call ends the turn with exactly one of: its real result, + the deferred sentinel (never executed), or the executed-but-unreported sentinel (bounded + wait expired). The bounded wait plus the sweep guarantee the turn cannot end with an + open call. + +Tests that pin the current behavior and bound the change: + +- `tests/unit/responder.test.ts:679` pins that a deferred sibling result never enters the + client-output store (must keep passing; extend for the new sentinel). +- `tests/unit/sandbox-agent-orchestration.test.ts:1753` pins that two racing ask gates each + emit a card and neither is force-settled (must keep passing). +- The test around `sandbox-agent-orchestration.test.ts:1690` pins that a non-gated sibling + IS settled with the deferred sentinel (still correct under the new contract: that call + held no approval). + +## R6. The turn-counter fix + +Question: where does the per-turn computation live, how does it behave with two pooled +environments serving one session, and how is the API-side conflict mapped? + +The bug, verified end to end: + +- `acquireEnvironment` computes `environment.continuityTurnIndex = nextTurnIndex(...)` + exactly once, at acquire time + (`services/runner/src/engines/sandbox_agent/environment.ts:962-964`). +- `nextTurnIndex` reads the process-wide `SessionContinuityStore` singleton: latest + recorded turn plus one, or 0 for a fresh session + (`services/runner/src/engines/sandbox_agent/session-continuity.ts:107-112`, store at + 24-99, singleton at 99). +- Warm turns bypass acquire entirely: the `hit-continue` branch calls + `engine.runTurn(live.environment, ...)` directly (`server.ts:608-623`), and the approval + resume branch does the same (`server.ts:742-766`), so the frozen index is re-used. +- At the end of every completed turn, `runTurn` records that index into the store and + fires the durable append with it (`run-turn.ts:566-597`); `appendSessionTurn` POSTs + `POST /sessions/turns/` (`session-continuity-durable.ts:157-198`). +- The API's `SessionTurnsDAO.append` is a bare add-and-commit with no IntegrityError + handling (`api/oss/src/dbs/postgres/sessions/turns/dao.py:33-50`; the file does not even + import `IntegrityError`), so the unique index + `ix_session_turns_project_id_session_id_turn_index` + (`api/oss/src/dbs/postgres/sessions/turns/dbes.py:37-43`, created by migration + `oss000000014_add_session_turns.py:70-75`) surfaces as a 500 through + `intercept_exceptions`. + +Why moving the computation to turn start is correct, including the two-environment case: + +- The store advances only on a successful `record()` (`session-continuity.ts:47-59`), and a + paused turn deliberately never records (`run-turn.ts:567` guards on + `stopReason !== "paused"`). So with a per-turn read, a park-and-resume cycle that spans + several runner turns still consumes ONE conversation index, recorded once by the turn + that completes. That is exactly the "true conversation-turn counter" semantics. +- Two pooled environments for one session (the `poolSize=2` shape from the 500 report: one + approval-parked, one idle) both read the SAME process-wide store at their next turn + start, so indexes stay monotonic per session, not per environment. The read-then-record + window is not concurrent in practice: one Node process, and the local provider forbids a + second replica serving the same session (`LocalSandboxNotOwnerError`, + `session-continuity.ts:157-210`). A true cross-replica collision on a remote provider + would now surface as a 409 the runner logs and drops, which is the accepted answer to the + 500 report's open retry question (retry is over-engineering today). +- Runner restarts stay correct: `acquireEnvironment` hydrates the store from the durable + rows before the first turn (`environment.ts:941-949`, + `hydrateHarnessSessionFromDurable`, `session-continuity-durable.ts:104-148`), and warm + turns keep using the live in-process store. + +The API-side mapping has an established in-repo pattern to copy: catch +`sqlalchemy.exc.IntegrityError`, match the constraint name, raise `EntityCreationConflict` +(`api/oss/src/core/shared/exceptions.py:4-25`), which `intercept_exceptions` converts to a +409 `ConflictException` (`api/oss/src/utils/exceptions.py:132-144`, default code 409 at +246-248). Copy targets: `api/oss/src/dbs/postgres/gateway/connections/dao.py:70-83`, +`api/oss/src/dbs/postgres/triggers/dao.py:84-108`, and the sibling +`api/oss/src/dbs/postgres/sessions/streams/dao.py:51-61`. The route is already wrapped in +`@intercept_exceptions()` (`router.py:1070`). + +The dev-database wipe: every `session_turns` row written while the bug was live carries an +acquire-counter index, not a turn index, so the whole table on the dev stack is suspect and +is wiped rather than repaired. The exact operational step is in plan.md step 1. + +## R7. The regression test plan + +The existing orchestration harness (`tests/unit/sandbox-agent-orchestration.test.ts:35-303`) +fakes the whole stack: a scripted `session` whose `prompt()` replays `promptEvents`, raises +scripted permission requests through the registered handler, and can hang until +`destroySession` resolves it (the Claude pause shape); a fake `run` (otel) or the real +`createSandboxAgentOtel`; and a `deps` bag injected into `runSandboxAgent`/`runTurn`. The +keepalive tests add two more seams (`tests/unit/session-keepalive-approval.test.ts:1-134`): +a dispatch-level fake engine that scripts each turn's park state for `runWithKeepalive`, +and an engine-level pausable fake harness for real `runTurn` park-and-resume mechanics +(describe block at line 1260; the two-parallel-gates case at line 1537). + +What is missing, and what the new tests must pin, is the real Pi shape: gates that are NOT +all known before the first pause. The shapes are specified in plan.md step 5; in summary: + +- Engine seam: turn 1 announces two ask-policy calls, gates only the first, parks; the + resume answers gate 1, the fake then emits an in-progress frame for call 1, raises gate 2 + (a second permission request DURING the resume turn), and delivers call 1's real + `completed` frame; assertions: call 1's real result is recorded exactly once and no + deferred sentinel ever attaches to it, gate 2 emits its own card, the turn re-parks; a + second resume answers gate 2 and the held prompt completes; each gate received exactly + one `respondPermission`. +- Dispatch seam: a two-gate park answered one card at a time resumes live twice and never + degrades to cold (rewriting the all-or-cold test at + `session-keepalive-approval.test.ts:579`). +- Records and hydration: a unit test that feeds `transcriptToMessages` a record list + containing a `tool_call`, its `interaction_request`, and an `interaction_response`, and + asserts the rebuilt part is `approval-responded` with the verdict; plus the negative + case (request without response stays `approval-requested`). + +## R8. Lane and PR mechanics (documentation only) + +Branches, from `gh pr view`: + +- PR #5382 `plan/concurrent-approvals`, GitHub base `release/v0.105.6`. In the GitButler + workspace it is stacked on `feat/deny-frame-egress` (PR #5383's lane). +- PR #5375 `sessions-rebase/backend`, base `main`. +- PR #5376 `sessions-rebase/runner`, base `sessions-rebase/backend` (stacked on #5375's + lane in the workspace). + +File-overlap audit (from `gh pr diff --name-only` on all three PRs): + +- The #5382 diff and the #5376 diff ALREADY share three files today: + `services/runner/src/engines/sandbox_agent/run-turn.ts`, + `services/runner/src/engines/sandbox_agent/runtime-contracts.ts`, and + `services/runner/src/server.ts`. The two stacks are parallel (different bases), and the + working tree holds both applied; their hunks do not conflict today. +- The planned steps keep each step's hunks on one lane, but they add more shared FILES + across the two stacks: step 1 edits `run-turn.ts` (the turn-index region, lines 566-597) + on the #5376 lane while steps 2 and 4 edit `run-turn.ts` (the pause and resume regions, + lines 88-95, 163-260, 439-520) on the #5382 lane; step 3 edits + `services/runner/src/protocol.ts` and `services/runner/src/sessions/persist.ts` (both in + the #5376 diff) and `api/oss/src/core/sessions/interactions/dtos.py` (in the #5375 diff) + on the #5382 stack. +- Verified feasibility of that file sharing: the regions step 3 edits are textually + identical on both stacks (`git show plan/concurrent-approvals:...` confirms the + `AgentEvent` union, the persist stable-id branch, and `resolveInteraction` exist + unchanged there, at that branch's own line numbers), so the hunks route cleanly. The + turn-index region that step 1 edits exists ONLY on the #5376 lane, so those hunks cannot + land anywhere else. +- Because of the shared files, commits MUST follow the one-lane-at-a-time isolation + procedure from the root `AGENTS.md` (assign exactly one lane's files, commit with + `--only`, verify with `git show --stat` and per-lane `git diff --name-only .. + ` before touching the next lane). This is the top operational risk of the whole + project and is restated in plan.md. + +The note to JP (drafted in plan.md step 1) covers: what the two amendments on his lanes do, +why the dev `session_turns` rows were wiped, and the confirmed `turn_index` semantics. + +## Open risks + +1. **The history fingerprint under partial resumes (R4).** Not conclusively verified that + `historyFingerprint` ignores approval tool-result envelopes; if it does not, a + partial-answer resume would mismatch and evict to cold. The step 4 dispatch-seam test + pins the intended behavior; the implementer must read + `session-identity.ts`'s `historyFingerprint` before coding and adjust the park-side + expected fingerprint if needed. +2. **Cross-stack file sharing (R8).** `run-turn.ts`, `protocol.ts`, `persist.ts`, + `server.ts`, `runtime-contracts.ts`, and `interactions/dtos.py` are all touched by both + this project and JP's rebase lanes. Hunk mis-routing here scrambles two open PR stacks + at once. Mitigation is procedural (isolation commits plus per-lane diff verification), + not structural. +3. **The cancellation-closure emitter (R5).** The exact upstream emitter of the turn-1 + closure frame (pi-acp's turn-cancellation path closing unstarted calls) is the one link + the incident report could not pin to a line, and this research did not close it either. + The fix does not depend on the emitter's identity (it classifies frames by policy + evidence on the runner side), but a harness that legitimately completes an ask-policy + call without any gate would be misrecorded as deferred. No such harness path exists + today (both harnesses fail closed), so this is accepted and documented in the code + comment the step 2 spec requires. +4. **`in_progress` frame coverage (R5).** The started-evidence signal (`in_progress` + status frames) was not verified across both harnesses' real streams; the specified + classification therefore rests on permission policy (fail-closed), with startedness as + a reinforcing signal only. Live QA (qa.md) covers the real-stream behavior. diff --git a/docs/design/agent-workflows/projects/approvals-incident-fixes/status.md b/docs/design/agent-workflows/projects/approvals-incident-fixes/status.md new file mode 100644 index 0000000000..82b8e0aac3 --- /dev/null +++ b/docs/design/agent-workflows/projects/approvals-incident-fixes/status.md @@ -0,0 +1,17 @@ +# Status + +2026-07-19: Planning complete, awaiting review. + +- Research R1 through R8 closed with code evidence; findings in `research.md`. Four open + risks are listed at the end of that file; the history-fingerprint question (risk 1) and + the cross-stack file sharing (risk 2) are the two that need attention during + implementation, and both have prescribed mitigations in `plan.md`. +- Plan written as five independently landable steps in `plan.md`, targeted at an + implementer (GPT-5.6 Sol / Codex) who has not read the incident conversation. +- QA script written in `qa.md`, including the MP4 recording requirement and the + release-gate cells to re-run. +- Nothing has been implemented, committed, or pushed from this planning session. The dev + `session_turns` wipe (plan step 1c) has NOT been run. + +Next action: Mahmoud reviews this workspace; on approval, hand `plan.md` to the +implementation agent, step 1 first. diff --git a/docs/design/agent-workflows/projects/concurrent-approvals/PLAN.md b/docs/design/agent-workflows/projects/concurrent-approvals/PLAN.md new file mode 100644 index 0000000000..31da75e6a1 --- /dev/null +++ b/docs/design/agent-workflows/projects/concurrent-approvals/PLAN.md @@ -0,0 +1,432 @@ +# Plan: multiple simultaneous approval requests in one turn + +Read [README.md](README.md) first for the shared vocabulary. Every domain word used below +(runner, harness, gate, approval card, park, latch, cold path, warm path, force-settle) is +defined there. + +## Summary + +When an agent tries to call several gated tools at the same moment in one turn, for example +"read these three files" where each read needs approval, the runtime shows the human only +the first approval card. It cancels the other gated calls, marks them "not executed, paused", +and lets the model ask for them again on later turns. The user sees the agent stall, approve +one thing, then loop back and ask for the next, turn after turn. GitHub issue #5373 reports +exactly this. + +The cause is a deliberate one-approval-per-turn guard in the runner (the latch). Every layer +above the runner already handles more than one pending approval: the wire carries one event +per gate, the SDK emits one frame per event, and the frontend already waits for every pending +card to be answered before it resumes. Only the runner caps the count at one, and only the +warm resume path lacks a place to store more than one parked gate. + +This plan removes the cap and pluralizes the two singular runner data structures, so each +gate emits its own approval card and each card is settled by its own tool-call id. It then +verifies with tests that the frontend and SDK, which are already written for the plural case, +actually work on both the cold and warm resume paths. No wire contract changes shape. The +work is runner-only for behavior, plus new tests on the frontend and SDK. + +## What the user sees today + +An agent turn can call more than one tool at once. Claude does this routinely: asked to read +three files, it emits three `read_file` tool calls in a single assistant turn. If those tools +are gated, the harness raises three approval gates in that one turn. + +The runner handles the first gate normally: it shows the approval card and parks the turn. +For the second and third gates it does something different. It counts them, but it does not +show their cards. It force-settles them as "not executed, paused" so they do not hang as open +calls. The turn ends with one card visible. + +The human approves the one card. The run resumes. The model, seeing the first tool now done +and the other two never executed, asks for them again. The runtime shows the next card. The +human approves again. This repeats once per remaining gate. + +The observable result, quoting #5373: "the interaction takes additional turns and does not +behave correctly." It still finishes, but every extra gate costs a full extra round of model +call, pause, and human click. + +### Where each piece of that behavior lives + +All runner paths below are current on `origin/main` (verified 2026-07-18; line numbers may +drift slightly as the file changes). + +- **The one-per-turn cap (the latch).** `PendingApprovalLatch` + (`services/runner/src/permission-plan.ts:173-185`) returns `true` on its first + `tryAcquire()` call and `false` on every later call. The runner constructs one latch per + turn (`services/runner/src/engines/sandbox_agent/run-turn.ts:256`). +- **The card emit is guarded by the latch.** `pauseUserApproval` + (`services/runner/src/engines/sandbox_agent/acp-interactions.ts:152-183`) fires its + `onUserApprovalGate` signal first (line 161), then runs `if (!latch.tryAcquire()) return;` + (line 169) before emitting the `interaction_request` card (lines 173-183). So the second + gate's signal is recorded but its card is never emitted. +- **The siblings are force-settled.** When the turn pauses, open gated calls that did not get + a card are settled as `TOOL_NOT_EXECUTED_PAUSED` + (`run-turn.ts:231-236`, and a post-drain re-sweep at `run-turn.ts:487-492`). +- **Only the first gate is remembered for a live resume.** The parked-gate record is written + only when `env.approvalGateCount === 1` (`run-turn.ts:350-368`, the `env.parkedApproval = + {...}` at 353-366). The record type `ParkedApproval` holds a single `permissionId`, + `toolCallId`, and `toolName` (`runtime-contracts.ts:112-127`). +- **The warm resume path refuses multi-gate turns outright.** The keep-alive dispatch + (`server.ts:419-437`) declines to live-park when more than one gate is pending: + `if ((env.approvalGateCount ?? 0) > 1) { klog("multi-gate-no-park ..."); return false; }` + (`server.ts:428-431`). Multi-gate turns fall back to the cold path. + +## Why the layers above the runner are not the problem + +The research trace (Question 2 of the ground-truth document) confirmed, and this plan +re-verified against `origin/main`, that everything above the runner already handles more than +one pending approval: + +- **The wire already carries one event per gate.** There is no batched multi-approval frame + and none is needed. Each gate is its own `interaction_request` with a distinct `toolCallId`. +- **The SDK already emits one approval frame per event.** In the browser-facing egress, + `_interaction_parts` yields exactly one `tool-approval-request` chunk per `user_approval` + event (`sdks/python/agenta/sdk/agents/adapters/vercel/stream.py:708-712`). Feed it three + events and it emits three frames. +- **The SDK ingress already keys each answer by tool-call id.** Each `approval-responded` part + the browser sends back becomes a `tool_result` block keyed by `toolCallId` + (`sdks/python/agenta/sdk/agents/adapters/vercel/messages.py:176-206`). Many answers become + many blocks. +- **The runner's cold-path decision store is already plural.** `extractApprovalDecisions` + returns a `Map`, a list of decisions per key + (`services/runner/src/responder.ts:358-373`), and `ConversationDecisions.take` consumes one + per key in order (`responder.ts:256-266`). The map holds many; the cold path answers them + one gate per replayed turn. +- **The frontend already waits for every pending card.** `agentShouldResumeAfterApproval` + finds the last freshly-answered card and then requires + `toolParts.every(isSettledToolPart)` before it resumes + (`web/packages/agenta-playground/src/state/execution/agentApprovalResume.ts:131-165`, + the all-settled check at 163-164). A second still-pending card keeps the run paused until + the human answers it too. +- **The parallel-park pattern already exists in the runner.** Browser-fulfilled "client" + tools already park more than one widget in one turn. `buildClientToolRelay` deliberately + ignores the latch and emits one widget per pending call + (`services/runner/src/engines/sandbox_agent/client-tools.ts:176-266`). It marks each parked + call (`markPausedToolCall`) so the force-settle sweep skips it, and it pauses the turn once + through an idempotent `pause()`. This is the exact shape the approval path should copy. + +The conclusion: the approval path is the only place that caps the count, and the warm resume +path is the only place missing a plural data structure. The frontend and SDK need no behavior +change; they need tests that prove the plural case works end to end. + +## The design + +Four steps. Steps 1 to 3 change the runner. Step 4 adds tests to the frontend and SDK. The +approved direction from Mahmoud is exactly these; this section elaborates them. + +### Step 1: remove the one-approval-per-turn cap so each gate emits its own card + +Make the approval path behave like the existing client-tool path: no latch, one card per +gate, one idempotent pause for the turn, and each parked call marked so the force-settle +sweep leaves it alone. + +- In `pauseUserApproval` (`acp-interactions.ts`), delete the `if (!latch.tryAcquire()) + return;` guard (line 169). The `onPausedToolCall?.(toolCallId)` call that already runs + (line 172) is the equivalent of the client path's `markPausedToolCall`; it keeps this gate + out of the force-settle sweep. The pause that ends the turn is already idempotent, so N + gates end the turn once. +- Decide what to do with `pauseClientTool`'s own latch use + (`acp-interactions.ts:195`). This is a separate, less-used path: the ACP-gate variant of a + client tool. Two options, decided in step 1's implementation: (a) remove its latch too, so + it matches the primary client-tool path, or (b) leave it, because the primary client + delivery does not go through it. Recommendation: remove it for consistency, since after + step 1 the latch guards nothing that should be capped. See open question 1. +- The `PendingApprovalLatch` class itself (`permission-plan.ts:173-185`) becomes unused for + approvals. Keep the class only if `pauseClientTool` still uses it; otherwise delete the + class, its construction in `run-turn.ts:256`, and the `latch` field threaded through + `acp-interactions.ts` and the `LatchLike` surface in `client-tools.ts:164-181`. Removing + dead code is preferred once no caller remains. + +**Contract after step 1:** the runner emits one `interaction_request` (`kind: +"user_approval"`) per ask gate in a turn, each with a distinct `toolCallId`. The turn ends +once. No force-settle happens for a gate that got a card. + +### Step 2: store every parked gate, not just the first + +Turn the singular parked-gate record into a collection keyed by tool-call id, so a live +resume can answer each gate. + +- In `runtime-contracts.ts`, add a plural field to `SessionEnvironment`. The record type + `ParkedApproval` (lines 112-127) stays as the per-gate shape. Change the environment to + hold `parkedApprovals` as a `Map` keyed by `toolCallId` (or an + array; a map is preferred because resume answers arrive keyed by `toolCallId`). Keep a + derived singular accessor only if a caller genuinely needs "the first gate" for logging. +- In `run-turn.ts` (the `onUserApprovalGate` handler, lines 350-368), record every gate into + the map instead of only when `env.approvalGateCount === 1`. Keep `approvalGateCount` as the + count; it now equals `parkedApprovals.size`. +- In `run-turn.ts`, adjust the force-settle early-return + (`if (opts.approvalParkMode && env.parkedApproval) return;`, near line 178) to check "this + tool call is in `parkedApprovals`" rather than "any single park exists". + +**Contract after step 2:** the runner remembers every parked gate for the current turn, +addressable by `toolCallId`. Nothing is emitted differently; this is internal state that the +warm resume in step 3 reads. + +### Step 3: let the warm resume path park a multi-gate turn and answer every gate + +The warm path (keep-alive) keeps the harness process alive across the pause and delivers the +human's answer live. Today it refuses any multi-gate turn and drops to the cold path. Relax +that, and answer each parked gate by its id. + +- In `server.ts` (`approvalToPark`, lines 419-437), remove the hard `approvalGateCount > 1` + refusal (lines 428-431). Replace it with a check that the turn is fully parkable: every + pending gate has a recorded `ParkedApproval` (a `permissionId` the harness can answer). If + any pending gate is non-parkable, for example a client-tool MCP pause that carries no + `permissionId`, keep the whole turn on the cold path, because the cold path is the only one + that can multiplex a mixed set today. The existing `non-parkable-gate-no-park` branch + (lines 424-426) already handles "no park at all"; the new check generalizes it to "not all + gates parkable". +- The warm-park bookkeeping that reads `env.parkedApproval?.promptPromise` + (`server.ts:448,475,510`) needs a representative promise for the parked set. Use any one + gate's `promptPromise` for the watchdog, or gate the watchdog on "all parked promises + settled". Decide in implementation; the simplest correct choice is to watch the set and + evict when any parked prompt rejects. See open question 2. +- In `runtime-contracts.ts`, generalize the resume input. `ResumeApprovalInput` (lines + 129-138) answers one gate. Change the warm resume to carry a list, for example + `ResumeApprovalInput[]` or a `{ decisions: ResumeApprovalInput[] }` batch. The server builds + this list from the inbound responded parts, which the SDK already keyed by `toolCallId`, and + which `extractApprovalDecisions` already returns as a map. +- In `run-turn.ts` (the live resume, `env.session.respondPermission(...)` at lines 454-457), + iterate the parked gates. For each, look up the human's decision by `toolCallId`, and call + `respondPermission(permissionId, reply)` once per gate. This assumes the harness holds + several pending permission requests concurrently and answers each independently. Confirm + this against the Claude ACP adapter during implementation and in the live test. See open + question 3. + +**Contract after step 3:** a warm resume can carry more than one decision and settle each +parked gate on the live harness session by its `permissionId`. If any gate in the turn cannot +be parked, the whole turn uses the cold path, exactly as a single non-parkable gate does +today. + +### Step 4: prove the frontend and SDK handle the plural case + +The frontend and SDK already read as plural. This step adds tests, and a small check that two +cards render sensibly, so the plural case is pinned and cannot regress. + +- **Frontend unit test.** Feed `agentShouldResumeAfterApproval` a turn with two + `approval-requested` parts. Assert it does not resume until both are answered, then resumes + once both are settled. This exercises the all-settled check + (`agentApprovalResume.ts:163-164`) with more than one card. +- **Frontend rendering check.** In the playground, confirm two approval cards in one turn + render as two distinct, independently answerable widgets, correctly attached to their own + tool bubbles by `toolCallId`. This is a manual live check plus, if feasible, a component + test. It also checks the interaction with issue #5078 (see composition below). +- **SDK unit tests.** Egress: feed `_interaction_parts` two `user_approval` events, assert two + `tool-approval-request` frames. Ingress: feed `messages.py` two `approval-responded` parts, + assert two `tool_result` blocks keyed by their two `toolCallId`s. These pin + `stream.py:708-712` and `messages.py:176-206` for the plural case. + +## File-level change list + +Behavior changes (runner): + +| File | Change | Contract effect | +| --- | --- | --- | +| `services/runner/src/engines/sandbox_agent/acp-interactions.ts` | Remove the latch guard in `pauseUserApproval` (line 169); decide the same for `pauseClientTool` (line 195). | One approval card per gate; turn ends once. | +| `services/runner/src/permission-plan.ts` | Remove `PendingApprovalLatch` (lines 173-185) once no caller remains. | Dead code removed; no behavior of its own. | +| `services/runner/src/engines/sandbox_agent/run-turn.ts` | Record every gate into `parkedApprovals`; drop the `approvalGateCount === 1` condition (lines 350-368); adjust the force-settle early-return (near 178); iterate gates in the live resume (454-457). | Every parked gate remembered and answerable. | +| `services/runner/src/engines/sandbox_agent/runtime-contracts.ts` | Add `parkedApprovals: Map` to `SessionEnvironment`; make the warm resume input a list. | Internal runtime contract goes plural; wire unchanged. | +| `services/runner/src/engines/sandbox_agent/server.ts` | Replace the `approvalGateCount > 1` refusal (428-431) with an "all gates parkable" check; adapt the parked-prompt watchdog to the set. | Warm path can live-park a multi-gate turn; mixed sets still fall to cold. | + +Test-only changes: + +| File | Change | +| --- | --- | +| `services/runner/tests/unit/...` (new or extended) | Two-gates-one-turn on the cold path and the warm path; force-settle no longer fires for a second gate; multi-answer warm resume. | +| `web/packages/agenta-playground/src/state/execution/agentApprovalResume.test.ts` | Two pending cards: no resume until both settled. | +| `sdks/python/oss/tests/pytest/unit/agents/...` | Egress two frames; ingress two keyed blocks. | +| Release gate journey spec (see test plan) | New scenario: agent calls two gated tools in one turn. | + +Client-tool client-tool paths (`client-tools.ts`, `responder.ts` cold store, `transcript.ts` +resume frames) need no change. They are already plural; the plan reuses their pattern. + +## What changes on the wire and in the frontend, and what stays + +**Stays the same:** + +- The wire event shape. One `interaction_request` with `kind: "user_approval"` per gate, each + with its own `toolCallId`, exactly as today. We remove an artificial per-turn cap on how + many flow; we do not add a field or a batched frame. +- The SDK frame shape. One `tool-approval-request` per event; one `tool_result` block per + answer, keyed by `toolCallId`. +- The frontend resume rule. Wait until every pending card is answered, then resume once. +- The approve/deny envelope (`{ approved: boolean }` keyed by `toolCallId`) that the cold and + warm paths both consume. + +**Changes:** + +- Runner-internal contracts only: the parked-gate record goes from one to a map, and the warm + resume input goes from one decision to a list. These types are internal to the runner and + its keep-alive server; they never cross the browser boundary. +- Observable behavior: a turn can now show two or three cards at once, and the warm path can + answer them in one resume instead of forcing one gate per replayed turn. + +Because the wire shape is unchanged, an older frontend paired with the new runner still works: +it renders each card as it arrives and answers each, since it already did that for the +single-card case. The improvement is that the runner now sends all the cards at once instead +of trickling them across turns. + +## Test plan + +**Runner unit tests.** + +- Two gated tool calls in one turn: assert two `interaction_request` events emitted, the turn + ends once, and neither gate is force-settled as `TOOL_NOT_EXECUTED_PAUSED`. +- Cold resume of a two-gate turn: assert both decisions are read from the replayed history and + both tools run without a second re-ask. +- Warm resume of a two-gate turn: assert the dispatch live-parks (does not log + `multi-gate-no-park`), the resume input carries two decisions, and `respondPermission` is + called once per parked gate. +- Mixed set: one approval gate plus one non-parkable client-tool pause in one turn stays on + the cold path (the "all gates parkable" check fails), matching today's single non-parkable + behavior. + +**SDK unit tests.** Egress emits two frames for two events; ingress emits two keyed blocks +for two answers. Add these to the existing vercel adapter test files. + +**Frontend unit test.** `agentApprovalResume` does not resume with a second card pending; +resumes once both settle. + +**Live playground scenario (the headline manual test).** Configure an agent whose tools are +gated with an ask rule. Prompt it to do something that calls two gated tools at once, for +example "read file A and file B" where `read_file` is gated. Confirm: two approval cards +appear together in the one turn; each is independently approvable and denyable; approving both +runs both tools and the agent continues in one resume, with no extra re-ask turns. Run this on +the cold path first, then repeat with the warm (keep-alive) path enabled, since the warm path +is the least-tested corner. + +**Warm-path variant checks.** With keep-alive on: approve both cards, confirm one resume +settles both on the live session. Then a deny variant: deny one and approve the other in the +same turn, confirm the denied tool reports a decline and the approved one runs. Then a partial +answer: answer one card and leave the other pending, confirm the run stays paused (the +frontend all-settled rule holds). + +**Release-gate journey spec.** Add a scenario to the agent release gate harness (the +`agent-release-gate` skill's wire-level QA harness) that drives the product endpoint with an +agent that calls two gated tools in one turn, and asserts on the SSE frame stream: two +`tool-approval-request` frames in the turn, both tool results present after the resume, and no +duplicate or re-asked approval frames in a later turn. This asserts on frames and side +effects, not model prose, so it runs against any deployment. Consider pinning the run as an +`agent-replay-test` once green. + +## How this composes with in-flight work + +Three pull requests touch the same files or the same wire area. This plan is written to sit +cleanly next to all three. + +- **Deny-frame egress, PR #5381 (branch `feat/deny-frame-egress`).** It added a structural + `denied` marker on the runner `tool_result` event and a `tool-output-denied` egress frame, + so a decline renders differently from a tool breakage. It touches `acp-interactions.ts`, + `run-turn.ts`, `protocol.ts`, `tracing/otel.ts`, and `stream.py`. This plan touches + `acp-interactions.ts` and `run-turn.ts` too, but in different concerns: #5381 marks a + gate's deny outcome, this plan changes how many gates emit and park. The deny marker is + per-`toolCallId`, so it composes naturally with per-gate approval: each of two cards can be + approved or denied, and each denied gate gets its own `tool-output-denied` frame. The + implementer should land after #5381 or rebase onto it, and add a warm-path test that mixes + approve and deny across two gates in one turn (listed in the warm-path variant checks above). +- **Sessions turns/streams runner, PR #5376 (branch `sessions-rebase/runner`), JP's work.** + It rewrites the keep-alive machinery: `server.ts` (the alive watchdog becomes awaited and + gains an `onInterrupted` abort callback, plus `streamId` threading), `run-turn.ts` (turn + completion becomes `appendSessionTurn`), `runtime-contracts.ts`, and `protocol.ts`. This is + the warm/keep-alive path, which is exactly where this plan's step 3 lives. The two changes + overlap in `server.ts`, `run-turn.ts`, and `runtime-contracts.ts`. Because the warm resume + is built on the sessions machinery, this plan's warm-path step should stack on top of the + sessions PRs, not race them. See the rebase story below. +- **Client tools on Daytona (being planned in parallel, projects + [daytona-gate-delivery](../daytona-gate-delivery/) and + [mcp-client-tool-continuation](../mcp-client-tool-continuation/)).** That work adds a new + park/resume channel so browser-fulfilled client tools can round-trip on Claude-in-Daytona. + It shares this plan's interest in parking more than one interaction per turn, but on a + different channel (the MCP client-tool path, not the ACP approval path). The two do not + conflict in files. The shared invariant both must keep: the frontend all-settled rule and + the "every pending interaction is its own widget, keyed by tool-call id" model. State that + invariant in both plans so neither regresses it. + +## Rebase story + +The warm-path step (step 3) overlaps JP's sessions runner PR #5376 in `server.ts`, +`run-turn.ts`, and `runtime-contracts.ts`. Sequence the work to avoid a three-way tangle: + +1. Land the cold-path-safe part of this plan first if it helps: steps 1 and 2 (remove the + latch, pluralize the parked record) plus their tests are almost independent of the sessions + rewrite. They change `acp-interactions.ts`, `permission-plan.ts`, and the record type. This + can go on its own lane based on `main`, or stacked under the deny-frame lane if that lands + first. +2. Base step 3 (the warm resume) on JP's sessions runner once it merges, or stack the + concurrent-approvals warm lane on top of `sessions-rebase/runner` if the timing forces + parallel work. Do not edit JP's branch. Re-home this plan's `server.ts` and `run-turn.ts` + edits onto whatever those files look like after the sessions rewrite, since the sessions PR + renames and moves the very functions step 3 changes (`startAliveWatchdog`, the turn + completion path). +3. If the deny-frame PR #5381 has not merged when step 3 lands, rebase onto it too, because it + also edits `run-turn.ts` and `acp-interactions.ts`. The concerns are disjoint, so the + rebase is mechanical, but the two must be reconciled in one working tree before pushing. + +Order to prefer: deny-frame (#5381) and sessions (#5375/#5376) land, then concurrent-approvals +steps 1 to 2, then step 3 on top of the sessions machinery. If steps 1 to 2 are ready before +sessions lands, they can go first since they do not touch the keep-alive files, and step 3 +follows. + +## Rollout and rollback + +- **Rollout.** Steps 1 and 2 change cold-path and shared behavior and should ship together so + a multi-gate turn shows all its cards at once even on the cold path. Step 3 improves the warm + path and can ship in the same change or immediately after, gated behind the same keep-alive + configuration that already controls warm resume. No new flag is required for the card-count + change; the wire is unchanged and the frontend already handles it. +- **Rollback.** Reverting steps 1 to 2 restores the latch and the single-card-per-turn + behavior; the frontend and SDK tolerate this because it is today's behavior. Reverting step 3 + restores the `multi-gate-no-park` refusal, so multi-gate turns fall back to the cold path, + which still works, just with the extra re-ask turns. Each step is independently revertible. +- **Risk.** The warm resume answering several concurrent permission requests is the least + proven behavior. The live warm-path variant checks are the gate on shipping step 3. If the + Claude ACP adapter turns out to serialize gates rather than hold them concurrently (open + question 3), step 3's benefit shrinks to the cold path, which steps 1 and 2 already fix. + +## Non-goals + +- **No relay-executed ask parking.** Giving the runner-side relay loop a way to park a + resolved code or gateway tool that carries an ask rule is a separate, larger build tracked + as S5.2 in [../../scratch/open-issues.md](../../scratch/open-issues.md). This plan only + covers harness-raised ACP approval gates, which already have a park mechanism. +- **No cross-surface approval records.** Making an approval raised on one surface answerable + from another (for example a run started by an API client and approved in the playground) is + the multi-surface roadmap, not this plan. +- **No new client-tool channel.** Client tools on Claude-in-Daytona are the parallel + daytona-gate-delivery work. +- **No trace-join or usage-summing change.** Issue #5097's trace continuity work is assessed, + not owned, here (see composition and issue links). + +## Open questions for review + +1. **The `pauseClientTool` latch (`acp-interactions.ts:195`).** After step 1 the latch caps + nothing that should be capped. Remove it there too for consistency, or leave that ACP-gate + client-tool corner alone because the primary client delivery does not use it? Recommendation: + remove, and delete the now-dead `PendingApprovalLatch` class. +2. **The warm parked-prompt watchdog with a set of gates.** The keep-alive eviction watches + one gate's `promptPromise` today (`server.ts:448`). With several parked gates, watch any one, + or watch the whole set and evict when any parked prompt rejects? The set is safer; confirm it + does not over-evict. +3. **Does the Claude harness hold several permission requests concurrently in one turn?** Step + 3 assumes the ACP adapter raises multiple gates that each await their own `respondPermission`. + Issue #5373 (parallel file reads) strongly implies yes, but this must be confirmed against the + Claude ACP adapter and in the live test before step 3 is trusted. If Claude serializes gates + instead, the cold-path fix (steps 1 to 2) still resolves #5373, and step 3 becomes a smaller + optimization. +4. **Issue #5078 (approved tools appear multiple times).** Its root cause is a cold-replay + re-mint of the `toolCallId` on resume, addressed on the frontend in PR #5058 by deduplicating + on tool identity. Removing the multi-turn re-ask (this plan) reduces how often that resume + re-mint happens for the multi-gate case, so it should reduce #5078 occurrences, but it does + not replace the frontend dedup. Confirm during the live rendering check that two cards plus + the dedup do not interact badly. Treat #5078 as assessed and likely reduced, not owned. + +## Issue links + +- **Plans #5373** ("HITL breaks on multi-file approval flow"). This is exactly the multi-gate + breakage: several gated tool calls in one turn, capped to one card, re-asked across turns. The + implementation pull request that follows this plan will close it. +- **Assesses #5078** ("Approved tools appear multiple times in chat"). Related. Root cause is a + cold-replay `toolCallId` re-mint fixed on the frontend in #5058; this plan reduces the re-ask + turns that trigger it but does not own the fix. +- **Assesses #5097** ("Join a turn's approval-resume requests into one trace"). Related. Fewer + resume requests per turn touch the problem, but #5097's client-side trace replay is orthogonal + and still needed; this plan does not subsume it. diff --git a/docs/design/agent-workflows/projects/concurrent-approvals/README.md b/docs/design/agent-workflows/projects/concurrent-approvals/README.md new file mode 100644 index 0000000000..72f9f613c1 --- /dev/null +++ b/docs/design/agent-workflows/projects/concurrent-approvals/README.md @@ -0,0 +1,52 @@ +# Concurrent approvals: more than one approval request in a single turn + +This workspace plans one change to the agent runtime: let a single agent turn ask the +human to approve more than one tool call at the same time. Today the runtime shows exactly +one approval card per turn even when the agent tried to call several gated tools at once. +The extra calls are cancelled and re-requested on later turns, which the user sees as the +agent stalling and repeating itself. + +## Reading order + +- [PLAN.md](PLAN.md): the whole plan. Read it top to bottom. It opens with what the user + sees today, explains why, then gives the design, the file-level changes, the interface + effect, the test plan, how this composes with three in-flight pull requests, the rebase + story, rollout and rollback, non-goals, and open questions. + +There is only one plan document. This README exists to define the shared vocabulary once so +PLAN.md can lean on it. + +## Vocabulary (defined once here) + +- **Runner**: the Node/TypeScript service that runs one agent turn: it talks to the model + harness, streams events back to the browser, and executes or gates tool calls. Code lives + under `services/runner/`. +- **Harness**: the model driver the runner speaks to over a local protocol called ACP + (Agent Client Protocol). The two harnesses are Claude (Anthropic's agent SDK) and Pi. +- **Gate**: a point where a tool call needs a human yes or no before it runs. A gate that + needs a human is an "ask" gate. +- **Approval card / approval request**: the inline "Run this tool? Approve / Deny" widget + the playground shows for an ask gate. On the wire it is one `interaction_request` event + with `kind: "user_approval"`. +- **Park**: end the current turn while a gate waits for the human, then continue the same + logical turn once the human answers. Parking is how the run pauses without failing. +- **Latch**: a one-shot guard object (`PendingApprovalLatch`) that today allows only the + first approval card in a turn to be shown and silently blocks the rest. +- **Cold path (cold-replay)**: the resume style that rebuilds the whole conversation as + text and sends it to a fresh model turn. The human's answer is read back out of that + replayed history. +- **Warm path (keep-alive)**: the resume style that keeps the same harness process and ACP + session alive across the pause, so the human's answer is delivered live to the waiting + harness instead of being replayed as text. +- **Force-settle**: mark a gated tool call as "not executed, paused" so it does not hang as + an open call when its sibling gate parked the turn. +- **Frontend / FE**: the playground UI, in `web/packages/agenta-playground/` and + `web/oss/`. +- **SDK**: the Python agent SDK in `sdks/python/agenta/`. It converts between the browser's + message format and the runner's event stream. + +Related prior work this plan builds on: [../hitl-fix/](../hitl-fix/) (the original +approve/deny round-trip fix) and [../cold-replay-stopgaps/](../cold-replay-stopgaps/) (the +text-replay hardening). The ground-truth code trace this plan is built on lives at +[../../scratch/research-client-tools-and-concurrent-hitl.md](../../scratch/research-client-tools-and-concurrent-hitl.md), +Question 2. diff --git a/docs/design/agent-workflows/projects/sessions-takeover/architecture.md b/docs/design/agent-workflows/projects/sessions-takeover/architecture.md new file mode 100644 index 0000000000..f7fc2828bc --- /dev/null +++ b/docs/design/agent-workflows/projects/sessions-takeover/architecture.md @@ -0,0 +1,1159 @@ +# Session storage architecture + +This document describes every storage plane an agent session touches, at working depth, +verified against the code and against real rows in the EE dev database. It is written for +a senior engineer who does not live in this codebase. Every claim carries a file and line +reference. Where the in-flight work (PRs #5375 backend, #5376 runner, #5382 approvals) +differs from `origin/main`, the difference is called out explicitly; all file:line +references are to the current workspace tree, which has those lanes applied. + +Two wrong mental models circulated recently and are worth killing on page one: + +1. **"The `session_turns` table stores the conversation."** It does not. The conversation + content lives in two places: the harness's own native session file (a JSONL file on the + session's durable filesystem) and the `records` table (our own append-style event log, + in the tracing database). `session_turns` is a small ledger of turn metadata whose main + job is to remember which harness-native session id belongs to our session so the next + turn can resume natively. +2. **"The streams plane stores the streamed frames."** It does not. Nothing in + `session_streams` or Redis holds a single token of output. The runner persists every + event to the records plane promptly, whether or not anyone is watching. The streams + plane is liveness and ownership: who is running this session right now, on which + machine, and has anyone asked it to stop. + +## Vocabulary you need before anything else + +A **session** is one conversation between a user and an agent. It is identified by a +`session_id`, a caller-supplied string (in practice a UUID minted by the frontend), +validated against `^[a-zA-Z0-9_\-]{1,128}$` (`api/oss/src/dbs/redis/sessions/contract.py:122`). +It is deliberately not a foreign key anywhere: sessions may originate outside our database, +so every table stores it as a bare string correlator scoped by `project_id` +(`api/oss/src/dbs/postgres/sessions/turns/dbas.py:18-19`). + +A **turn** is one user-message-to-agent-reply exchange. Two different identifiers both get +called "turn" and confusing them causes real bugs: + +- The **turn id** (`turn_id`) is a UUID minted once per *execution*. It is a lock value + and correlator, not a database primary key. The runner mints it when a session-owned run + starts (`services/runner/src/server.ts:175-177`); the API mints its own in `_start_turn` + when a run is started through the coordination endpoint + (`api/oss/src/core/sessions/streams/service.py:543`). Approval pauses end an execution, + so one conversation turn that pauses twice for approvals spans three turn ids. This id + rides heartbeats, record rows, and interaction rows. +- The **turn index** (`turn_index`) is a per-session counter of *completed conversation + turns*: 0, 1, 2. It is the ordering key of the `session_turns` ledger. Paused executions + do not consume an index (`services/runner/src/engines/sandbox_agent/run-turn.ts:98-102`). + +A **harness** is the coding-agent program the runner drives: Pi or Claude Code. The +enum is `HarnessKind` with values `pi_core`, `pi_agenta`, and `claude` +(`sdks/python/agenta/sdk/agents/dtos.py:45-53`). The runner talks to the harness over +**ACP**, the Agent Client Protocol, a JSON-RPC protocol in which the harness owns its own +conversation state and exposes `session/new` and `session/load` verbs. + +The **agent session id** (`agent_session_id`) is the harness's *own* native id for the +conversation. Claude Code generates its ids internally; they cannot be derived from +anything we hold, which is the whole reason a durable mapping table has to exist. Pi's id +appears in its transcript filename (see the mount listing later in this document). + +The **runner** is the Node sidecar (`services/runner/`) that executes turns. A **replica** +is one runner container, identified by `REPLICA_ID`, a uuid minted once per process or +injected via `AGENTA_RUNNER_REPLICA_ID` (`services/runner/src/sessions/alive.ts:31-32`). + +A **sandbox** is the isolated environment (local daemon or Daytona) where the harness +actually runs. A **mount** is a durable object-store prefix attached into that sandbox via +geesefs, a FUSE filesystem over S3. + +With that vocabulary, the six planes this document covers are: + +| Plane | Where it lives | One-line job | +|---|---|---| +| Records | `records` table, **tracing** database | The human-readable event transcript the UI rebuilds from | +| Turns | `session_turns` table, core database | The completed-turn ledger: harness session id mapping and per-turn metadata | +| Streams + alive | `session_streams` table plus Redis keys | Liveness, ownership, and the control-signal path | +| Interactions | `session_interactions` table, core database | The stateful approval plane (pending, resolved, verdicts) | +| Mounts | `mounts` table plus the object store | The durable filesystem: workspace files and harness session files | +| Keepalive pool | Runner process memory | Parked live harness processes with TTLs; not storage at all | + +## 1. The life of one turn + +This walk uses the real QA session `6c23ff5f-79b7-45e3-82c3-52758d0f5fbe` (run on the EE +dev stack on 2026-07-19; the user asked the agent to append a line to two files via two +parallel Bash commands, each gated on human approval). Every step below left a row or log +line quoted later in this document. + +**Before the turn.** The user types a message in the agent chat. The frontend holds the +session list and the conversation locally (localStorage, +`web/oss/src/components/AgentChatSlice/state/sessions.ts:76-111`) and sends the message +through the normal workflow invoke path (`/invoke` via the Python agent service). The +invoke request carries `session_id`; it does not carry a turn id, so the runner will mint +one (`services/runner/src/server.ts:161-177`). + +**Turn start, coordination plane.** The runner sees the request is session-owned (it has a +`sessionId`) and starts the alive watchdog before running anything +(`services/runner/src/server.ts:956-978`). The first heartbeat, `POST +/sessions/streams/heartbeat`, does three things in one round trip +(`api/oss/src/core/sessions/streams/service.py:258-383`): + +- claims the `owner::session:` Redis key for this replica (affinity, never + stolen from a live owner), +- establishes the `alive` and `running` Redis locks under this turn id, +- creates or updates the durable `session_streams` row and returns its uuid, the + `stream_id`, which the runner threads onto the request for later use + (`services/runner/src/server.ts:976-978`). + +**Turn start, interactions and records.** The runner cancels any prior turn's still-pending +approval gates (`services/runner/src/sessions/interactions.ts:137-160`), then persists the +inbound user message as the first record of the turn, with `record_source: "user"` +(`services/runner/src/server.ts:1003-1007`). + +**Environment acquire, mounts.** The runner asks the API to sign short-lived, prefix-scoped +credentials for the session's durable mounts (`POST /sessions/mounts/sign`, +`services/runner/src/engines/sandbox_agent/mount.ts:68-131`) and geesefs-mounts them so the +agent's working directory survives sandbox teardown. Pi's native transcript directory is +placed *inside* that working directory, so it rides the same mount +(`services/runner/src/engines/sandbox_agent/pi-assets.ts:37-50`). + +**Continuation decision.** The runner decides how the harness gets its memory of the +conversation, in this order: warm resume from the keepalive pool if a compatible live +process is parked (`services/runner/src/server.ts:585-662`), otherwise a cold environment +that attempts a native `session/load` using the `agent_session_id` from the turns ledger +(`services/runner/src/engines/sandbox_agent/environment.ts:927-1004`), otherwise a plain +`session/new` with the prior conversation replayed as text +(`services/runner/src/engines/sandbox_agent/run-turn.ts:146-148`). + +**During the turn.** Every ACP event the harness emits is forwarded to the live stream +*and* enqueued for durable persistence in produced order, whether or not the browser is +still connected (`services/runner/src/sessions/persist.ts:95-122`). Delta families are +coalesced (one `message` record instead of fifty `message_delta`s), and tool-family records +get deterministic uuid5 ids so retries upsert rather than duplicate +(`services/runner/src/sessions/persist.ts:160-329`, +`services/runner/src/sessions/record-id.ts:41-47`). The watchdog heartbeats every 30 +seconds; each response can carry `is_current_turn: false`, which means a cancel, steer, or +kill took this turn's lock since the last beat, and the runner aborts the in-flight run +(`services/runner/src/sessions/alive.ts:165-207`). + +**If a tool needs approval.** The harness raises an ACP permission request. The runner +creates a durable interaction row (`services/runner/src/engines/sandbox_agent/run-turn.ts:390-409`), +persists an `interaction_request` record, parks the live process in the keepalive pool in +state `awaiting_approval`, and ends the execution. The user's approval arrives as a fresh +run request carrying the decision; the runner checks the parked process out, answers the +harness in place (a warm resume), transitions the interaction row to `resolved` with the +verdict, and persists an `interaction_response` record +(`services/runner/src/engines/sandbox_agent/run-turn.ts:417-447`, +`services/runner/src/server.ts:664-744`). + +**Turn end.** On a *completed* execution (not paused, not failed) the runner records the +harness's native session id in its in-memory continuity store and appends one row to the +`session_turns` ledger, fire-and-forget: session id, stream id, turn index, harness kind, +agent session id, sandbox id, workflow references, trace id +(`services/runner/src/engines/sandbox_agent/run-turn.ts:832-866`). A paused or failed +execution instead *invalidates* the harness's continuity record, because the harness may +have written a partial turn into its native file and resuming it would replay history the +canonical request never sent (`run-turn.ts:867-870, 887-892`; +`services/runner/src/engines/sandbox_agent/environment.ts:1056-1070`). The persist chain is +drained so the last record lands before teardown (`services/runner/src/server.ts:1021`), +the watchdog releases with a final `is_running: false` heartbeat +(`services/runner/src/sessions/alive.ts:215-219`), and the environment is either parked +warm in the keepalive pool or destroyed. + +```mermaid +sequenceDiagram + participant U as User (browser) + participant W as Web app + participant A as API + participant R as Runner + participant H as Harness (Pi/Claude) + participant S as Sandbox + mount + + U->>W: types message + W->>A: POST /invoke (session_id, messages) + A->>R: /run (sessionId, no turnId) + Note over R: mints turn_id (server.ts:175) + R->>A: POST /sessions/streams/heartbeat + Note over A: Redis: owner + alive + running locks
DB: session_streams row (streams plane) + A-->>R: stream_id, is_current_turn + R->>A: POST /sessions/interactions/cancel-stale + R->>A: POST /sessions/records/ingest (user message) + Note over A: records plane (async via Redis stream) + R->>A: POST /sessions/mounts/sign + A-->>R: scoped S3 credentials (mounts plane) + R->>S: geesefs mount cwd (+ harness session dir) + R->>H: session/load(agent_session_id) or session/new + Note over H,S: harness reads/writes its native
session file on the mount + loop every event + H-->>R: ACP event + R-->>W: live stream frame + R->>A: POST /sessions/records/ingest (records plane) + end + opt approval gate + H-->>R: session/request_permission + R->>A: POST /sessions/interactions/ (interactions plane) + Note over R: park in keepalive pool (memory) + U->>W: approve + W->>A: /invoke (decision) + A->>R: /run (same sessionId) + R->>H: respondPermission (warm resume) + R->>A: interactions/transition (resolved + verdict) + end + H-->>R: turn complete (agent_session_id) + R->>A: POST /sessions/turns/ (turns plane, fire-and-forget) + R->>A: heartbeat is_running=false + Note over R: park warm or destroy +``` + +## 2. Records: the durable transcript + +**One sentence.** The records plane is the append-style event log of everything said and +done in a session, stored per event in the tracing database, and it is what the UI replays +when you open a session in a browser that never ran it. + +**The question it answers.** "The user opens session 6c23ff5f from a deep link three days +later. What does the chat render?" Answer: `POST /sessions/records/query` returns the +ordered rows, and `transcriptToMessages` folds them into the same UI messages a live stream +would have produced (`web/oss/src/components/AgentChatSlice/assets/loadSession.ts:20-36`, +`web/oss/src/components/AgentChatSlice/assets/transcriptToMessages.ts:4-17`). + +### Schema + +Table `records`, in the **tracing** database (`agenta_ee_tracing` on the dev stack), not +the core database. The DAO binds to the analytics engine +(`api/oss/src/dbs/postgres/sessions/records/dao.py:21-25`); the table was created by +tracing migration `oss000000002_add_records.py` and extended with turn and span columns by +`oss000000004_add_records_turn_span.py` (unmerged, PR #5375). + +Columns (`api/oss/src/dbs/postgres/sessions/records/dbas.py`, +`api/oss/src/dbs/postgres/sessions/records/dbes.py`): + +- `project_id`, `record_id`: the composite primary key. `record_id` is either a + producer-supplied deterministic uuid5 (tool-family events) or a backend-minted uuid4 + fallback; it is **not** time-ordered (`dbas.py:24-33`). +- `session_id`: bare string correlator. +- `record_index`: producer-stamped per-turn ordinal. It restarts at 0 on each execution, so + reads order by ingest time first and index second + (`dao.py:112`, comment at `dbas.py:41-46`). +- `timestamp`: producer (runner) event time, distinct from `created_at` (ingest time). +- `record_type`: the event type (`message`, `thought`, `tool_call`, `tool_result`, + `interaction_request`, `interaction_response`, `usage`, `done`, `error`). +- `record_source`: who authored it, `"agent"` or `"user"` + (`services/runner/src/sessions/persist.ts:18-19`). +- `attributes`: the full event payload as JSONB, truncated at 64 KB at the producer + boundary (`api/oss/src/core/sessions/records/streaming.py:22, 64-77`). +- `turn_id`, `span_id`: forward-fill-only bridge columns, added by the unmerged migration. + `turn_id` is the execution turn id (the lock value); `span_id` is the OTel span id of the + invoke span, a 16-hex string that is deliberately **not** a UUID + (`api/oss/src/core/shared/dtos.py:53-57`, `dbas.py:7-21`). Old rows stay null; the + tracing database is never backfilled. + +### Who writes, who reads, when + +The **runner** is the only producer. `buildPersistingEmitter` wraps the live emitter so +every event is both streamed and persisted, decoupled from client connection state +(`services/runner/src/sessions/persist.ts:160-329`). Writes go to +`POST /sessions/records/ingest` authenticated as the invoke caller; the route does not +write the database directly. It publishes to the Redis stream `streams:records` +(`api/oss/src/apis/fastapi/sessions/router.py:508-538`, +`api/oss/src/core/sessions/records/streaming.py:51-97`), and a separate worker consumes +batches, applies EE ingest quotas, and upserts rows +(`api/oss/src/tasks/asyncio/sessions/records_worker.py:19-160`). The write is therefore +asynchronous: a record is durable in Redis quickly and in Postgres within the worker's +batch delay. On a local stack with the worker not running, records silently never land, +which is a documented frontend fallback case (`loadSession.ts:16-18`). + +Ordering within a session is guaranteed by a per-session promise chain in the runner +(`persist.ts:34-35, 95-122`), three retries with backoff per event, and a drain before +teardown so the last event survives the race (`persist.ts:129-137`, +`server.ts:1021`). A persist failure after retries is logged and dropped. + +Readers: the frontend replay path (`querySessionRecords` via the Fern client, +`web/packages/agenta-entities/src/session/api/api.ts:56-77`) and the two records read +endpoints (`router.py:439-506`). + +### Lifecycle and mutability + +This is the subtlety the word "append-only" hides: the write is an **upsert**, not an +insert. `ON CONFLICT (project_id, record_id) DO UPDATE` replaces the body, timestamp, and +turn/span columns of an existing row (`dao.py:84-97`). For randomly-minted ids that never +matters. For tool-family records the id is deterministic: +`uuid5(session_id:tool_call_id:record_type)` (`record-id.ts:41-47`), which is what makes a +streamed snapshot of one tool call, or a resume that re-announces it, settle onto one row +instead of duplicating. The cost is that the id is **not turn-scoped**: a later result for +the same tool call id silently overwrites the earlier row, preserving nothing. Incident +db58551b demonstrated this rewriting history (defect 5, +`docs/design/agent-workflows/scratch/debug-concurrent-approvals-db58551b.md`). The fix +plan (scope stable ids by turn) is agreed but not implemented. Until then, treat records as +a faithful *rendering* log and an unfaithful *audit* log. + +Deletion: never deleted by the session delete fan-out. The root delete explicitly leaves +records alone because they live in another database; tracing retention owns them +(`api/oss/src/core/sessions/service.py:83-91`). + +### Real rows + +From `agenta_ee_tracing.records`, session 6c23ff5f (the QA session): + +``` +record_id | a1aed416-34c4-40ae-a2e1-71299944e802 +session_id | 6c23ff5f-79b7-45e3-82c3-52758d0f5fbe +record_index | 0 +timestamp | 2026-07-19 21:33:32.372+00 +record_type | message +record_source | user +turn_id | 0cf4b750-7ddb-45d1-89b1-c2ef24802823 +span_id | cc57875a43d73a25 +created_at | 2026-07-19 21:33:32.661106+00 +attributes | {"text": "Append the line \"hello from QA\" to agent-files/README.md and + to agent-files/NOTES.md, as two separate Bash commands issued in parallel + in the same turn.", "type": "message"} +``` + +And the answer half of a gate, the `interaction_response` record type added by the +approvals work (note the deterministic uuid5 record id, and that its `turn_id` +`1eea4b3d...` differs from the request's `0cf4b750...`: the answer landed on the warm +resume execution, not the execution that asked): + +``` +record_id | 526453ac-b565-5dd1-8199-6d11504fb15d +record_type | interaction_response +record_index | 2 +turn_id | 1eea4b3d-b810-4125-a65c-faffc242551a +attributes | {"id": "56efc8f2-1830-44f5-87c3-62d8ab598997", "kind": "user_approval", + "type": "interaction_response", + "payload": {"approved": true, "toolCallId": "toolu_016ZdXAopbBhuFDqKCV5Pyvv"}} +``` + +The full type distribution for this session: 7 message, 4 done, 3 thought, 2 each of +interaction_request, interaction_response, tool_call, tool_result, usage. + +### Intended design and unmerged parts + +The `turn_id` and `span_id` columns (unmerged migration `oss000000004`) exist to group a +session's records by turn and to bridge each turn to observability. The +`interaction_response` record type is on the open #5382 lane; `origin/main`'s protocol has +no answer event at all, which is the root cause of the "answered cards resurrect as +pending on reload" bug (defect 4 of the db58551b report). + +### Sharp edges + +- `api/oss/src/core/sessions/records/utils.py` contains `strip_replay` and + `coalesce_events`, ported from the PoC, with **no callers** anywhere in the API (verified + by repo-wide grep) and no producer of the `__replay__:` marker in the runner or SDK. The + replay-stripping invariant its docstring describes is currently enforced by nothing. +- On an approval resume, the recovered prior prompt is re-persisted as a fresh user + message row, duplicating it in the transcript (db58551b defect 5, still open). +- `record_index` is only meaningful within one execution; cross-turn ordering rides on + ingest time (`dao.py:112`), so a clock or worker-batching anomaly can interleave turns. + +## 3. Turns: the completed-turn ledger + +**One sentence.** `session_turns` is one row per *completed* conversation turn, storing no +conversation content: it maps our session id to the harness's native session id, records +which harness and sandbox served the turn, and carries per-turn metadata (turn index, +workflow references, trace id, stream id). + +**The question it answers.** "The runner restarted overnight and its memory is empty. The +user sends the next message in session 6c23ff5f. Can the harness resume natively, and with +which of its own session ids?" Answer: read the latest turn row; if this harness authored +the latest completed turn, `session/load` its `agent_session_id`; otherwise fall back to +text replay (`services/runner/src/engines/sandbox_agent/session-continuity-durable.ts:104-148`). + +### Why a ledger, and why it replaced the states blob + +On `origin/main` this job is done by `session_states`, a mutable one-row-per-session JSON +blob that the runner syncs with a GET-then-PUT read-modify-write +(`git show origin/main:services/runner/src/engines/sandbox_agent/session-continuity-durable.ts`, +module docstring). That shape has two structural problems: the read-modify-write races +with itself across executions, and a blob folds away history, so you cannot ask "which +sandbox served turn 3". The unmerged redesign (#5375/#5376) replaces it with an +append-only table where a write is a plain INSERT, no read, no merge, no race +(`session-continuity-durable.ts:151-163` in the workspace tree), and the *current* resume +pointer is a query, not a stored fold: `ORDER BY turn_index DESC LIMIT 1`, which a late +lower-index write can never win (`api/oss/src/core/sessions/turns/service.py:1-6, 69-79`). +The states table is physically dropped by migration +`oss000000017_drop_session_states.py`. + +Separately, the ledger is deliberately **not** the conversation and **not** the harness +file. The harness file is the only faithful representation of what the model saw (including +its own native caching and tool-call framing); duplicating it would drift. What the +platform needs is a trust decision ("is the native file a faithful resume point?") plus +lookup keys, and that is exactly what the ledger stores. + +### Schema + +Table `session_turns`, core database, created by unmerged migration +`oss000000014_add_session_turns.py`. Columns +(`api/oss/src/dbs/postgres/sessions/turns/dbas.py`, +`.../turns/dbes.py`): + +- `project_id`, `id`: composite primary key; `id` is a uuid7, so insertion-ordered. +- `session_id`: bare string, NOT NULL, indexed with project. +- `stream_id`: NOT NULL foreign key to `session_streams(project_id, id)`. Every turn is + written with the stream row id in hand because the first heartbeat returned it + (`dbes.py:26-30`, `services/runner/src/server.ts:976-978`). +- `turn_index`: NOT NULL, with a **unique** index on `(project_id, session_id, + turn_index)` (`dbes.py:37-43`). This index is what caught the warm-environment + double-append bug (see sharp edges). +- `harness_kind`: `pi_core` / `pi_agenta` / `claude`, enum-validated at the DTO. +- `agent_session_id`: the harness-native session id. Nullable, because a harness might not + surface one. +- `sandbox_id`: which sandbox served the turn, e.g. `local/127.0.0.1:43699` or a Daytona + id. Read by the sandbox-reconnect ladder. +- `references`: JSONB list of `{id, slug, version}` entity references (the workflow, + variant, and revision this turn ran), GIN-indexed with `jsonb_path_ops` for containment + queries (`dbes.py:44-49`). This is what the root session list joins on. +- `trace_id`, `span_id`: observability bridge. `trace_id` is a 128-bit OTel trace id + stored as UUID; `span_id` would be the 16-hex span id (`dbas.py:36-38`). +- `start_time`, `end_time`: reserved timing columns. + +### Who writes, who reads, when + +One writer: the runner, at the end of a completed execution only. +`run-turn.ts:835-866` is the gate, and reading it precisely matters: + +```ts +if ( + stopReason !== "paused" && + env.continuityTurnIndex !== undefined && + sessionId && + env.session?.agentSessionId +) { + ...store.record(...); + void (deps.appendSessionTurn ?? appendSessionTurn)(...) +``` + +So a row is written only when the turn ran to completion AND the harness surfaced a native +session id. A pause (`stopReason === "paused"`) or any error path instead calls +`invalidateContinuity` (`run-turn.ts:867-870, 887-892`), which drops the in-memory record +so the harness can never be judged load-eligible from a possibly-partial native file. The +absence of a ledger row for the latest turn is therefore *information*: it means the +native file is not trusted and the next cold start must replay text. + +The append is fire-and-forget (`void ... .catch(() => {})`, `run-turn.ts:853-865`): a +failed append never fails the turn, it only degrades a future restart to replay. + +Readers: + +- `hydrateHarnessSessionFromDurable` at environment acquire, which restores the + cross-harness latest-turn counter first and then this harness's own record, never + clobbering a live in-process record with a stale read + (`session-continuity-durable.ts:104-148`, + `environment.ts:937-948`). +- The sandbox-reconnect ladder, which reads the latest row's `sandbox_id`. +- The root session list: `POST /sessions/query` with `references` filters resolves + matching sessions *through the turns' GIN index* rather than denormalizing references + onto the stream row (`api/oss/src/core/sessions/service.py:41-74`). +- The turns API surface: `POST /sessions/turns/` (append), `POST /sessions/turns/query`, + `GET /sessions/turns/{turn_id}` (`api/oss/src/apis/fastapi/sessions/router.py:1065-1178`). + +### The continuation decision tree + +Eligibility is strict: a harness may `session/load` if and only if it authored the +conversation's most recently completed turn (`session-continuity.ts:114-130`). A harness +that is behind (the user switched from Pi to Claude and back) is stale and must replay, +because its native file is missing the intervening turns. + +```mermaid +flowchart TD + A["New turn for session S, harness H"] --> B{"Keepalive pool has an idle + compatible live process? + (server.ts 585-609)"} + B -- "yes, fingerprints match" --> W["WARM RESUME + same harness process, native memory + prompt = new text only"] + B -- "no / mismatch" --> C["Acquire cold environment"] + C --> D["Hydrate continuity store from + session_turns latest row + (environment.ts 940-948)"] + D --> E{"H authored the LATEST completed + turn AND agent_session_id known? + (session-continuity.ts 121-130)"} + E -- yes --> F["COLD NATIVE LOAD + session/load with agent_session_id + harness reads its own file off the mount + (environment.ts 963-990)"] + F -- "load verified" --> G["prompt = new text only + (run-turn.ts 147-148)"] + F -- "load failed" --> H["TRANSCRIPT REPLAY"] + E -- "no row / stale / invalidated" --> H + H --> I["session/new; prompt = prior conversation + rendered as capped text + new message + (run-turn.ts 148, transcript.ts)"] +``` + +### Real rows + +Both completed turns of QA session 6c23ff5f, from `agenta_ee_core.session_turns`. Note +what is present (the resume keys, the references, the trace id) and what is absent (any +conversation content, and `span_id`/`start_time`/`end_time`, see sharp edges): + +``` +id | 019f7c4d-44e6-77f2-9f7b-2409615f9092 +project_id | 019e8df5-2a58-7501-8fe2-56f7b332bd00 +session_id | 6c23ff5f-79b7-45e3-82c3-52758d0f5fbe +stream_id | 019f7c4c-713f-7a23-a764-14552c3b9f6a +turn_index | 0 +harness_kind | pi_core +agent_session_id | 019f7c4c-7a8f-7c80-bd0f-05458ab62fab +sandbox_id | local/127.0.0.1:32905 +references | [{"id": "019f5b6c-bfa9-7073-a8aa-203b215cea22", "slug": "new-agent-mrj6ykqaa2"}, + {"id": "019f5b6c-bfef-7fa0-b0cc-14b52b3b6734", "slug": "new-agent-mrj6ykqaa2.default"}] +trace_id | f06ec815-62d9-cde0-0092-f99cff2e1b09 +span_id | (null) +start_time | (null) +end_time | (null) +created_at | 2026-07-19 21:34:26.534971+00 + +id | 019f7c4f-1e80-7861-9fb4-84d204967da4 +turn_index | 1 +agent_session_id | 019f7c4c-7a8f-7c80-bd0f-05458ab62fab +sandbox_id | local/127.0.0.1:43699 +trace_id | 981b03a3-0618-2355-c618-44bed54988ee +created_at | 2026-07-19 21:36:27.776117+00 +``` + +Both turns carry the *same* `agent_session_id` (one continuous Pi conversation) but +*different* `sandbox_id`s: turn 1 ran on a different local daemon port after the first +environment was torn down, and native continuity still held because Pi's session file +lives on the durable mount, not in the sandbox. + +### Intended design and unmerged parts + +The entire plane is unmerged (PRs #5375/#5376). The two bugs found during QA on 2026-07-19 +are fixed on those lanes: `turn_index` is now computed per turn from the shared store, not +frozen per environment acquire (commit `cae71e49ce`, `run-turn.ts:98-102`; the original +failure is documented in +`docs/design/agent-workflows/scratch/debug-session-turns-append-500.md`), and a duplicate +append now surfaces as HTTP 409 via `EntityCreationConflict` instead of an anonymous 500 +(commit `494cdd7996`, `api/oss/src/dbs/postgres/sessions/turns/dao.py:48-68`). + +Deletion is a hard delete by session id, part of the delete fan-out +(`turns/dao.py:182-196`). There is no soft delete for turns. + +### Sharp edges + +- `span_id`, `start_time`, and `end_time` are wired through the whole stack (wire model, + DTO, column) but the runner's append call passes only `streamId`, `agentSessionId`, + `sandboxId`, `references`, and `traceId` (`run-turn.ts:857-863`), so those three columns + are null in practice, as the real rows show. +- The ledger row does not carry the execution `turn_id` (the lock value that records and + interactions are stamped with). There is currently **no direct join** from a records row + to its ledger row; the only shared key is `stream_id` plus time. Anything that wants + "the records of turn 2" has to go through the trace id or timestamps. +- Because rows exist only for completed turns, a session whose last execution paused has a + ledger that is one turn behind reality, by design. Consumers must treat "latest row" as + "latest *trusted* resume point", not "latest activity". + +## 4. Streams and the alive lock: liveness and ownership + +**One sentence.** The streams plane is one durable row per session plus a nest of Redis +locks, telling the platform whether the session is claimed, whether a turn is executing +right now, who is watching, and which runner replica owns it; it stores no output. + +**The question it answers.** "Two browser tabs send a message to session S at the same +moment. Who wins, and how does the loser find out?" Answer: the first `send` acquires the +`alive` lock in `_start_turn`; the second gets `SessionTurnInUse` and an HTTP 409 whose +body carries the liveness snapshot (`api/oss/src/core/sessions/streams/service.py:103-119, +536-561`, `router.py:146-153`). + +### The Redis nest + +The contract file is the canonical source of truth and the TypeScript side mirrors it +exactly, pinned by a golden fixture test +(`api/oss/src/dbs/redis/sessions/contract.py:1-30`, +`services/runner/src/sessions/contract.ts:1-8`). Every key is project-scoped because +`session_id` is caller-supplied and only unique per project; omitting the project segment +would let a caller in project A cancel project B's runs (`contract.py:13-19`). + +| Key | Value | TTL (default) | Meaning | +|---|---|---|---| +| `alive::session:` | turn_id | 3600 s | Session claimed; at most one in-flight run | +| `running::session:` | turn_id | 3600 s | A turn is actively executing right now | +| `attached::session:` | watcher_id | 60 s | A client is watching the live view | +| `owner::session:` | replica_id | 120 s | Which runner container owns this session | +| `displaced::session:` | (pub/sub) | n/a | Attach-steal notifications | + +TTL defaults live in `api/oss/src/utils/env.py:1286-1317` (`AGENTA_SESSIONS_REDIS_*`). +The nest invariant is `alive ⊇ running ⊇ attached`: attached implies running implies +alive. `resumable` (alive and not running) and `reattachable` (running and not attached) +are derived client-side, never stored (`streams/dtos.py:12-22`). Lock operations are +owner-checked Lua scripts (release-if-owner, claim-or-read; +`contract.py:87-106`, implementations in `api/oss/src/dbs/redis/sessions/locks.py`). +`claim_owner` never steals from a live different owner; kill uses the unconditional +`force_clear_owner` twin precisely because a surviving owner key would lock the dead +session out of every other replica for the rest of its TTL (`locks.py:262-318`, +`streams/service.py:227-230`). + +### The durable row + +Table `session_streams`, core database, merged on main (migration `oss000000008`); the +header columns are the unmerged migration `oss000000015`. One row per `(project_id, +session_id)` (`api/oss/src/dbs/postgres/sessions/streams/dbes.py:22-74`): + +- `id`: uuid7; this is the `stream_id` the turns ledger references. +- `session_id`: bare string, unique with project. +- `flags`: JSONB `{is_alive, is_running, is_attached}`, the durable **mirror** of the + Redis nest. Redis is authoritative; the row mirrors for durability, orphan sweeps, and + observability (`dbes.py:33-41`). The read path overlays the live Redis snapshot onto the + row before returning it (`streams/service.py:385-409`). +- `turn_id`: the current execution's turn id, the Postgres mirror of the lock value; null + when idle (`dbes.py:48-50`). The heartbeat handler also uses it to disambiguate "first + heartbeat of a new turn" from "the lock was taken by a cancel" + (`streams/service.py:287-303`). +- `name`, `description`: the session header, written only by the rename edit, never by + the heartbeat path, so liveness writes cannot churn identity fields and vice versa + (`streams/dtos.py:54-59`, `streams/service.py:422-464`). +- `updated_at` doubles as the heartbeat timestamp (`dbes.py:37`). +- `sandbox_id` is deliberately NOT here; it lives on the latest turns row (`dbes.py:40`). + +### The command matrix + +`POST /sessions/streams/` is a state mutation over the lock nest, and it runs nothing +itself; the runner is the only component that executes +(`streams/dtos.py:76-89`, `streams/service.py:1-13`). The mode is derived from the +request shape: + +| inputs | force | Mode | Effect (`streams/service.py:81-177`) | +|---|---|---|---| +| yes | no | `send` | 409 if alive; else mint turn_id, acquire alive+running, stamp row | +| yes | yes | `steer` | force-drop holder's locks, mint new turn | +| no | no | `cancel` | force-drop locks, mark row ended, run nothing | +| no | yes | `attach` | steal the attached lock, return a watcher_id | + +`kill` (`DELETE /sessions/streams/`) is distinct from cancel: cancel ends the current +turn and the session can resume; kill ends the session. It collapses the whole nest +including owner affinity, displaces any watcher, calls the runner's own `/kill` directly +so the sandbox is actually torn down rather than left to idle-TTL eviction, marks the row +ended, soft-deletes it, and cancels every pending interaction +(`streams/service.py:202-256`, +`api/oss/src/core/sessions/streams/runner_client.py:30-62`, +`router.py:296-324`). + +### The heartbeat and the control-signal path + +The runner heartbeats every 30 seconds while a session-owned turn runs +(`services/runner/src/sessions/alive.ts:21-23, 165-223`). Each beat carries two distinct +ids: `replica_id` (the container, drives owner affinity) and `turn_id` (the execution, +proves lock ownership) (`streams/dtos.py:99-103`). The handler claims ownership without +stealing; a replica that lost the claim mutates nothing and is told the true owner +(`streams/service.py:266-284`), which the runner uses to refuse serving a local sandbox +session on the wrong host (`session-continuity.ts:157-210`). + +The response carries `is_current_turn`. It is false when this turn's lock was gone or +reassigned at the moment of the beat, disambiguated against the row's recorded `turn_id` +so a turn's very first heartbeat is not misread as an interruption +(`streams/service.py:287-336`, dtos docstring `streams/dtos.py:112-127`). The runner wires +this to `controller.abort()`, firing at most once: this is the plumbing that makes a +cancel, steer, or kill actually reach an in-flight run, with a worst-case latency of one +heartbeat interval (`alive.ts:147-207`, `server.ts:962-974`). Before W7.4 landed on this +lane, a cancel raced against the heartbeat's nx re-acquire would silently re-arm the same +lock and the interruption never surfaced. + +The heartbeat response also carries the `session_streams` row id, which is how the runner +gets the `stream_id` for the turn append without an extra round trip +(`alive.ts:52-59`, `server.ts:976-978`). + +```mermaid +sequenceDiagram + participant R as Runner (replica REPLICA_ID) + participant A as API + participant X as Redis + participant P as Postgres session_streams + + loop every 30 s while turn runs + R->>A: heartbeat {session_id, replica_id, turn_id, is_running:true} + A->>X: claim_owner(replica_id) - never steals + alt lost the claim + A-->>R: {replica_id: other, is_current_turn:false} + Note over R: refuse local session on wrong host + else owner + A->>X: refresh alive+running (owner-checked) + Note over A: refresh failed AND row.turn_id == this turn
=> a cancel/steer/kill took the lock + A->>P: mirror flags + turn_id, updated_at = now + A-->>R: {stream, replica_id, is_current_turn} + opt is_current_turn == false + R->>R: controller.abort() - at most once + end + end + end + R->>A: final heartbeat is_running=false + A->>X: clear running (alive outlives the turn) +``` + +### Real row + +From `agenta_ee_core.session_streams`, the QA session: + +``` +id | 019f7c4c-713f-7a23-a764-14552c3b9f6a +project_id | 019e8df5-2a58-7501-8fe2-56f7b332bd00 +session_id | 6c23ff5f-79b7-45e3-82c3-52758d0f5fbe +turn_id | 0cf4b750-7ddb-45d1-89b1-c2ef24802823 +flags | {"is_alive": true, "is_running": true, "is_attached": false} +created_at | 2026-07-19 21:33:32.351413+00 +updated_at | (null) +name | (null) +description | (null) +``` + +Read this row with the mirror caveat in mind: it still says alive and running two days +after the session went idle, and `updated_at` is null even though the deployed runner +logged successful `running=false` heartbeats. The dev deployment predates part of the +in-flight lane, and the row is exactly what the design says it is, a best-effort mirror. +The Redis keys have long expired, so `fetch` (which overlays the live snapshot, +`streams/service.py:385-409`) reports the truth while the raw row does not. Never read +`flags` off the table directly. + +### Intended design and unmerged parts + +The command matrix, kill's direct runner hop, the header edit, `is_current_turn`, the +hard-delete and archive verbs, and the concurrency cap +(`check_runner_concurrency_limit`, 429 above `AGENTA_SESSIONS_REDIS_CONCURRENCY_LIMIT`, +default 1000 running streams per project, `streams/service.py:530-534`, +`router.py:264`) are all on the unmerged #5375 lane. The frontend deliberately does not +surface steer, cancel, or attach yet, because without runner cooperation beyond W7.4 they +would be lock edits with no user-visible effect +(`web/packages/agenta-entities/src/session/api/api.ts:276-280`). + +### Sharp edges + +- `HEARTBEAT_WRITE_THRESHOLD_SECONDS` exists in both contracts (`contract.py:37`, + `contract.ts:19`) but nothing consumes it; every heartbeat currently writes the row. +- The heartbeat is fail-open on network error (`alive.ts:57-59, 99-104`): a transient API + blip neither aborts a healthy run nor fabricates a stream id, but it also means a + partitioned runner keeps executing while its locks expire, and a `send` on another + replica could then start a second run of the same session. The owner guard only + protects local sandboxes (`session-continuity.ts:189-210`). +- `count_active` counts rows whose mirrored flags say running (`streams/dao.py:274-292`); + a stale mirror (see the real row above) inflates the concurrency count until an orphan + sweep or the next heartbeat corrects it. + +## 5. Interactions: the stateful approval plane + +**One sentence.** `session_interactions` holds one row per human-in-the-loop gate (an +approval request, a user-input request, or a client tool call), with a lifecycle status +and, since the current approvals lane, the stored verdict; it is the plane that knows +whether anyone still needs to answer something. + +**The question it answers.** "Which approvals in this project are still waiting on a +human, and when the user clicks approve on a webhook-delivered card, how does the platform +resume the right workflow?" Answer: query rows with `status = 'pending'` (there is a +partial index for exactly this, +`api/oss/src/dbs/postgres/sessions/interactions/dbes.py:41-45`), and the row's stored +workflow `references` let the respond endpoint re-invoke the same workflow revision with +the answer as inputs (`router.py:764-858`). + +### Schema + +Table `session_interactions`, core database, merged on main (migration `oss000000009`). +Columns (`interactions/dbas.py`, `interactions/dbes.py`): + +- `project_id`, `id`: composite primary key. +- `session_id`, `turn_id`: correlators; `turn_id` is the execution id, used by the + stale-gate sweep to spare the current turn's own gates. +- `token`: the gate's identity, unique per `(project_id, session_id, token)` + (`dbes.py:19-24`), which is what makes the runner's fire-and-forget ingest retry-safe. +- `kind`: `user_approval` | `user_input` | `client_tool` + (`interactions/dtos.py:10-13`). +- `status`: lifecycle only, **not** the verdict: `pending` (awaiting a reaction), + `responded` (answered via the interactions API plane), `resolved` (answered via the + messages plane, or the runner consumed the answer), `cancelled` (runner abandoned the + gate; no one is waiting) (`dtos.py:16-22`). +- `data`: JSON with `request` (tool name and args), `references` (pointers to the + workflow/variant/revision this turn was running, stored so respond can re-resolve the + live revision at invoke time, not a copy of the revision itself, + `services/runner/src/sessions/interactions.ts:19-25`), `selector`, and `resolution` + (the verdict: `{"verdict": "approved"|"denied", "tool_call_id": ...}`). +- `flags`: delivery bookkeeping `{delivered_in_band, delivered_webhook}`. + +### Lifecycle + +Transitions are guarded in the DAO: only `pending` and `responded` rows can transition; +`resolved` and `cancelled` are terminal (`interactions/dao.py:92-135`). The respond +endpoint does a compare-and-set flip to `responded` first, so of two concurrent +responders exactly one enqueues the resume invoke (`router.py:802-829`). Resolution +payloads are validated: only a `user_approval` may carry one (`router.py:643-668`). + +```mermaid +stateDiagram-v2 + [*] --> pending: runner ingests gate (run-turn.ts 390-409) + pending --> responded: API-plane answer, CAS (router.py 802-819) + pending --> resolved: in-band answer consumed by runner (interactions.ts 102-126) + responded --> resolved: runner consumes the stored answer + pending --> cancelled: stale-gate sweep (dao.py 137-160) or session kill (router.py 319-323) + resolved --> [*] + cancelled --> [*] + note right of resolved + verdict stored in data.resolution + only on the number-5382 lane + end note +``` + +### Who writes, who reads, when + +The **runner** creates a row whenever an execution pauses on a gate +(`run-turn.ts:390-409` calling `interactions.ts:57-95`), idempotently (unique token). It +transitions the row to `resolved`, now with the verdict, when it forwards a stored or +in-band decision to the harness (`run-turn.ts:417-447`, `interactions.ts:102-126`). At +every turn start it sweeps *other* turns' still-pending gates to `cancelled`, sparing +gates the incoming request answers in-band (`server.ts:979-988`, +`interactions.ts:128-160`, `dao.py:137-160`). The **API** writes on the respond path +(the CAS flip) and on kill (cancel everything pending). + +Readers: the interactions query/fetch endpoints (`router.py:711-762`), and any future +inbox UI. The frontend today rebuilds approval cards from *records*, not from this table, +which is why persisting the answer half as an `interaction_response` record mattered so +much (db58551b defect 4). + +### Real rows + +A resolved gate carrying the verdict (the new shape, from a 2026-07-20 session on the dev +stack): + +``` +id | 019f7f14-d642-78a2-baf3-ecf7773759bc +session_id | 782ff288-d47e-40ac-b277-f29d79c833d1 +token | 2b8cdd00-27f1-4fd0-9994-1d2af50ca3a3 +kind | user_approval +status | resolved +resolution | {"verdict": "approved", "tool_call_id": "call_P70SW0Y8VEBrfhT78JZpnQZi|fc_039f2633..."} +created_at | 2026-07-20 10:31:39.843001+00 +updated_at | 2026-07-20 10:32:01.213554+00 +``` + +And the incident session db58551b's first gate, the pre-verdict shape (status flipped, +`turn_id` null, no resolution stored; note the full request and references in `data`): + +``` +id | 019f79cb-62e1-7b40-96fe-207ab3381e87 +session_id | db58551b-f986-44ec-b939-d6b10b35717a +token | f6f8384d-51f4-4bba-9b78-ca299f45465c +kind | user_approval +status | resolved +data | {"request": {"tool": "Bash", "args": {"command": "printf '\nParallel write + test completed.\n' >> agent-files/README.md"}}, + "references": {"workflow": {...}, "workflow_variant": {...}, + "workflow_revision": {"version": "1", ...}}} +flags | {"delivered_in_band": true, "delivered_webhook": false} +created_at | 2026-07-19 09:53:20.097197+00 +updated_at | 2026-07-19 09:53:22.469477+00 +``` + +### Intended design and unmerged parts + +The verdict (`resolution`) and the `interaction_response` record are on the open #5382 +lane ("reliable multi-gate approvals"); on `origin/main` the schema field exists but no +producer writes it, which is exactly what the db58551b incident report identified as the +root defect. The `respond_task` seam in the router lets the resume invoke run on a taskiq +worker when wired, with an inline blocking invoke as the fallback +(`router.py:821-857`). + +### Sharp edges + +- The runner refuses to create the row when the run context carries no + `workflow_revision` reference (`run-turn.ts:399-400`), because respond could not + re-invoke anything. This is why QA session 6c23ff5f has interaction *records* but zero + `session_interactions` rows (verified against the dev database): its run context carried + workflow and variant references but no revision. Gates in that state are answerable + in-band only, invisible to any interactions-plane inbox. +- `turn_id` is nullable and was null on the older rows above; the stale-gate sweep's + "spare the current turn" logic only works when the runner stamps it. +- The row's `data.request` stores raw tool args. These are redacted by the runner's + redactor on the records plane but go to interactions verbatim; treat this table as + sensitive as the transcript. + +## 6. Mounts: the durable filesystem + +**One sentence.** A mount is a per-session object-store prefix (bucket key prefix +`mounts//`) that geesefs attaches into the sandbox as a real +directory, carrying both the agent's workspace files and the harness's native session +files, which is what makes native continuation survive sandbox teardown. + +**The question it answers.** "The sandbox that served turn 0 is gone. Turn 1 starts on a +brand new sandbox. Where does Pi find the conversation it is supposed to resume?" Answer: +in `agents/sessions/pi/` inside the session's `cwd` mount, which the new sandbox mounts +before the harness starts; the file's name carries the `agent_session_id` from the turns +ledger. + +### Storage model + +The `mounts` table (core database, migration `oss000000006`, plus session-slug scoping in +`oss000000011` and `agent_id` in the unmerged `oss000000016`) stores mount *identity*: +`project_id`, `id`, optional `session_id`, optional `agent_id`, a slug, and a name +(`api/oss/src/core/mounts/dtos.py:25-34`). File contents live in the object store +(SeaweedFS on the dev stack, any S3 on prod), under +`[namespace/]mounts///` +(`api/oss/src/core/mounts/service.py:145-155`). A session mount's slug is deterministic: +`__ag__session____` (`mounts/service.py:72-78`), so re-signing +re-attaches the same files. + +The sessions router exposes a session-scoped view over the same table (no storage of its +own, `api/oss/src/core/sessions/mounts/service.py:1-6`): list, query, upload, download, +and the important one, `POST /sessions/mounts/sign?session_id=...&name=...` +(`router.py:861-1062`). Sign get-or-creates the named mount for the session and returns +short-lived credentials scoped to that prefix, minted from the store's STS endpoint; the +master key never leaves the API (`api/oss/src/core/mounts/dtos.py:97-113`). The `name` +parameter defaults to `cwd`; any other name creates an additional session-scoped mount +with its own prefix, which is how per-harness transcript mounts work +(`router.py:996-1005`, `mount.ts:59-67`). + +### Where the harness session files actually live + +This is the detail that keeps getting misremembered, so here it is per harness and per +sandbox type (`services/runner/src/engines/sandbox_agent/mount.ts:133-179` and +`pi-assets.ts:31-50`): + +- **Pi, everywhere:** Pi's transcript directory is pointed *into the workspace* via + `PI_CODING_AGENT_SESSION_DIR = /agents/sessions/pi`. Since cwd IS the durable + `cwd` mount on both local and Daytona runs, Pi's native files ride that one mount and + no separate transcript mount is needed. The separate `pi-sessions` mount name exists + for the case where Pi's default home-relative dir is in use + (`mount.ts:174-177`) but the runner always overrides via the env var. +- **Claude, remote (Daytona) sandboxes:** Claude Code keeps transcripts under + `~/.claude/projects`. That directory gets its own mount, name `claude-projects`, + signed and geesefs-mounted inside the sandbox (`mount.ts:171-172, 724-761`). + Deliberately NOT `~/.claude` whole, which would sweep OAuth credentials into the + durable store (`mount.ts:162-164`); credentials are re-injected per run instead + (`mount.ts:136-139`). +- **Claude, local sandboxes:** none of the harness-dir mounting runs. `~/.claude` is the + runner container's own disk (`mount.ts:716-719`), which persists across turns only as + long as that container lives. A local Claude session's native continuity does not + survive a runner container rebuild even though the turns ledger row does. + +### Who writes, who reads, when + +The runner signs and mounts at environment acquire; the agent reads and writes through +the FUSE mount for the whole turn; teardown unmounts. The API reads and writes the store +directly for the browser file endpoints (upload/download, +`router.py:1025-1062`) and deletes prefixes on the session delete fan-out +(`api/oss/src/core/sessions/service.py:100-103`). Archive and unarchive soft-toggle the +mount rows alongside the stream row (`service.py:109-146`). Sign failure is never fatal: +the run proceeds without the mount, degraded (`mount.ts:64-67, 84-90, 749-756`). + +### Real listing + +The QA session's single mount row, then the actual store listing showing the harness +session file sitting next to the workspace files. The Pi JSONL filename embeds the same +`agent_session_id` (`019f7c4c-7a8f...`) the turns ledger rows carry, which is the whole +mapping made visible: + +``` +id | 019f7c4c-7166-76f1-8477-37bcc3cede5d +project_id | 019e8df5-2a58-7501-8fe2-56f7b332bd00 +session_id | 6c23ff5f-79b7-45e3-82c3-52758d0f5fbe +agent_id | +slug | __ag__session__de851259-3492-5fab-a416-f249737f7d4d__cwd +name | cwd +created_at | 2026-07-19 21:33:32.390727+00 +``` + +``` +/buckets/agenta-store/mounts/019e8df5.../019f7c4c-7166-76f1-8477-37bcc3cede5d +├── AGENTS.md +├── agent-files/ (README.md, NOTES.md - the files the QA turn appended to) +└── agents + ├── sessions + │ └── pi + │ └── 2026-07-19T21-33-34-736Z_019f7c4c-7a8f-7c80-bd0f-05458ab62fab.jsonl + └── skills + └── 07b95dee.../ (.agenta-skill-set.json, build-an-agent/SKILL.md, references/...) +``` + +### Intended design and unmerged parts + +`agent_id` on the mount row (unmerged migration `oss000000016`) is the beginning of +agent-scoped (as opposed to session-scoped) durable directories, the agent-mount work +tracked in PR #5247. Direct geesefs write-through is the accepted default for harness +transcript dirs; copy-around-lifecycle is the documented fallback only if append-heavy +JSONL write amplification bites (`mount.ts:141-146`). + +### Sharp edges + +- The signed credentials expire in minutes; a parked keepalive session whose mount + credentials pass expiry is evicted to cold on resume rather than continued + (`server.ts:722-724`). +- Deleting a session deletes mount rows and prefixes, which includes the harness session + files, so native continuity is correctly destroyed with the session. But local Claude + files on the runner's own disk are outside that fan-out entirely. +- geesefs over an in-network store needs the tunnel endpoint for remote sandboxes + (`mount.ts:186-204`); a mis-detected endpoint degrades silently to no mount. + +## 7. The keepalive pool: memory, not storage + +**One sentence.** The keepalive pool is a per-replica in-process map of parked *live* +harness processes (sandbox, ACP connection, native model context all still warm), kept +for a short TTL so the next message in the same conversation continues in place; it +survives nothing and stores nothing durable. + +**The question it answers.** "The user replies within a minute. Does the platform pay a +cold sandbox start and a context re-feed?" Answer: no; the dispatch checks the pool key +`:`, validates fingerprints, checks the environment out, and runs +the turn against the same process (`services/runner/src/server.ts:585-624`). + +### Mechanics + +The pool (`services/runner/src/engines/sandbox_agent/session-pool.ts`) holds +`LiveSession` entries: an opaque environment handle, a config fingerprint, a history +fingerprint, a credential epoch, a state, an LRU stamp, and one idempotent teardown +closure (`session-pool.ts:30-43`). States are `busy`, `idle`, `awaiting_approval`, and +`destroyed` (`session-pool.ts:24`). Two park flavors exist with different TTLs, visible in +the QA session's own runner log: + +``` +[keepalive] park key=019e8df5...:6c23ff5f... ttl=300000ms state=awaiting_approval poolSize=2 +[keepalive] resume key=019e8df5...:6c23ff5f... gates=1 answered=1 carried=0 approve=1 reject=0 tool=Bash +[keepalive] park key=019e8df5...:6c23ff5f... ttl=60000ms state=idle (re-park) poolSize=2 +``` + +An idle park (60 s) awaits the next message; an approval park (300 s) awaits a human. On +checkout the dispatch validates: config fingerprint equality, history fingerprint +equality (an edited transcript must not continue a mismatched live memory), credential +epoch, and a fresh trailing user message; any mismatch evicts and degrades to cold +(`server.ts:585-607`). The approval-resume branch deliberately relaxes the config and +credential checks, because every approval reply carries freshly minted per-turn material +that can never equal the parked one, and requiring it would evict a perfectly good live +session on every approval (`server.ts:664-726`). Capacity is LRU with idle-only +eviction; parking is best-effort and a full pool simply tears the session down as before +(`session-pool.ts:186-232`). + +**Interaction with every durable plane.** The pool is why turn indexes must be computed +per turn from the shared store rather than per environment acquire (a warm environment +serves many turns; the frozen-index bug produced the duplicate `turn_index=0` appends in +the 500 report). A parked-then-resumed approval consumes one conversation turn index +across multiple executions and turn ids. A client disconnect during a session-owned run +does not abort the run but does veto parking, so a disconnected user's session is +destroyed at turn end (`server.ts:932-941`). + +**It is not storage.** A runner restart empties the map; the design degrades to cold +native load via the turns ledger, then to transcript replay, never to an error +(`session-continuity.ts:1-9`). Nothing in the pool is readable from outside the process; +the closest external signal is the streams plane's liveness flags. + +## 8. Cross-plane summary + +| Plane | Nature | Writer | Reader | Lifetime | Carries workflow refs? | Survives runner restart? | +|---|---|---|---|---|---|---| +| Records (`records`, tracing DB) | Upsert log (append-style, stable-id upserts) | Runner via async ingest worker | Web replay, records endpoints | Until tracing retention; untouched by session delete | No (only turn_id/span_id bridge) | Yes | +| Turns (`session_turns`, core DB) | Append-only INSERT, hard delete on session delete | Runner, at completed-turn end only | Runner hydrate/reconnect, root session query, turns endpoints | Until session delete | Yes (`references`, GIN) | Yes | +| Streams row (`session_streams`, core DB) | Mutable, 1 row per session | API only (heartbeat, commands, header edit) | Web session fetch, concurrency cap, turns FK | Soft-deleted by kill/archive, hard by delete | No | Yes (mirror may be stale) | +| Alive nest (Redis) | Ephemeral TTL locks | API only (runner drives via HTTP) | API (liveness, 409 bodies) | Seconds to an hour, TTL | No | Yes (Redis), but locks expire | +| Interactions (`session_interactions`, core DB) | Mutable rows, guarded transitions | Runner (create/resolve/sweep), API (respond CAS, kill cancel) | Interactions endpoints, future inbox | Until session delete (hard) or cancel (terminal state) | Yes (in `data.references`) | Yes | +| Mounts (rows + object store) | Filesystem (mutable), deterministic slugs | Agent through geesefs; API file endpoints | Agent, browser file panel, harness `session/load` | Until session delete (prefix wiped); archive is reversible | No (`agent_id` coming) | Yes | +| Keepalive pool | Process memory | Runner dispatch | Runner dispatch | 60 s idle / 300 s approval TTL | n/a | **No** | + +Authority rules worth memorizing: Redis beats the streams row for liveness; the harness's +native file beats everything for conversation content; the turns ledger beats the +in-memory continuity store after a restart but never beats a live in-process record +(`session-continuity-durable.ts:124-127`); records beat the browser's localStorage on +reload but localStorage is the fallback when records are absent. + +## 9. What is missing + +Six known gaps, each tied to the plane it lands on. + +**1. Agent references on records and streams.** The turns ledger carries workflow +references and mounts are growing `agent_id` (migration `oss000000016`), but neither the +records rows nor the `session_streams` row says which agent a session belongs to. The +session list can only be filtered per agent by joining through the turns' references +(`api/oss/src/core/sessions/service.py:57-67`), and a records row is agent-anonymous +forever. Lands on: records (new column, forward-fill only, tracing DB is never +backfilled) and streams (a reference or `agent_id` on the row, written by the first +heartbeat or the invoke path). + +**2. Trace id at turn start.** The turn append happens only at turn *end* +(`run-turn.ts:847-866`), so a turn that pauses, crashes, or is cancelled leaves no ledger +row and no turn-to-trace link, and even a healthy turn is invisible to trace-join queries +until it completes. `span_id`, `start_time`, and `end_time` are already in the schema but +never populated (the append passes only `traceId`, `run-turn.ts:857-863`). The fix shape +is a start-of-turn write (or enriching the heartbeat's row stamp) plus passing the span +and timing fields at completion. Lands on: turns, with the streams heartbeat as the +plausible carrier. + +**3. Session titles have no home.** The mutable `session_states` table is dropped +(migration `oss000000017`) and the streams row grew `name`/`description` with a dedicated +rename endpoint that cannot collide with liveness writes +(`PUT /sessions/streams/header`, `streams/service.py:411-464`, migration +`oss000000015`). But the frontend still keeps titles in localStorage +(`AgentChatSession.title`, +`web/oss/src/components/AgentChatSlice/state/sessions.ts:34-40`) and the rename UI in +`SessionTagBar` writes only local state. Until the chat writes and reads the header, a +title exists only in the browser that set it. Lands on: streams (server side is done on +the unmerged lane) and the frontend session state. + +**4. Cross-plane deletion.** `DELETE /sessions/` fans out to turns, interactions, mounts +(rows and prefixes), and the stream row (`api/oss/src/core/sessions/service.py:76-107`), +but: records are untouched by design (cross-database, tracing retention owns them, +`service.py:90-91`), live Redis keys are not cleared by delete (only kill collapses the +nest), local Claude native files on the runner's disk are outside any fan-out, and the +browser's localStorage copy of the conversation survives everything. A "delete this +conversation" product promise currently leaves the transcript readable in two places. +Lands on: every plane; records and the frontend cache are the real holes. + +**5. Cancel and steer.** The lock-side machinery is complete on the unmerged lane (the +command matrix, `force_cancel_alive`, W7.4's `is_current_turn` wired to +`controller.abort()`), but the signal reaches the run only at heartbeat granularity (up to +30 s), abort is the bluntest possible cancel (no graceful harness stop, no partial-turn +record), and the frontend deliberately does not expose steer or cancel because the +user-visible effect would be a stalled stream (`api.ts:276-280`). Steer additionally needs +the "new text supersedes parked work" dispatch semantics that db58551b defect 6 showed are +missing. Lands on: streams (signal), keepalive pool and runner dispatch (semantics), +records and turns (how an aborted turn is represented; today it is only an invalidated +continuity record and no ledger row). + +**6. Live mid-turn attach.** `attach` mode steals the attached lock and returns a +`watcher_id`, and the displaced channel exists for teardown notifications +(`streams/service.py:158-177`, `contract.py:60-73`), but there is no endpoint that +replays the in-flight turn's frames to a new watcher; the live stream is a property of the +original `/run` HTTP response. A reload today gets the records replay (complete only up to +the persist chain's lag) and then silence until the turn ends. Real mid-turn attach needs +a frame buffer or a Redis-stream relay per turn on the runner or API. Lands on: streams +(the lock half exists), records (the catch-up source), and a missing delivery path that +belongs to neither yet. + +## 10. Glossary + +- **ACP.** Agent Client Protocol; the JSON-RPC protocol between the runner and a harness, + with `session/new`, `session/load`, `session/prompt`, and permission-request verbs. +- **agent session id.** The harness's own native id for a conversation + (`session_turns.agent_session_id`). Claude's cannot be derived; Pi's appears in its + transcript filename. +- **alive lock.** Redis key asserting "at most one in-flight run per session"; value is + the owning turn id; cleared only by kill or TTL, so a session can be alive but idle. +- **attach / watcher.** Attach mode claims the `attached` lock for a `watcher_id`, + displacing any prior watcher via the `displaced` pub/sub channel. +- **cold native load.** Continuation tier two: a fresh environment whose harness resumes + its own session file via `session/load` with the ledger's `agent_session_id`. +- **gate.** A human-approval request raised by the harness mid-turn; stored as an + interaction row and an `interaction_request` record. +- **geesefs.** FUSE-over-S3 filesystem used to attach mount prefixes into sandboxes. +- **harness.** The coding-agent program the runner drives: Pi (`pi_core`, `pi_agenta`) or + Claude Code (`claude`). +- **heartbeat.** The runner's 30-second `POST /sessions/streams/heartbeat`, refreshing + locks, claiming ownership, mirroring the row, and carrying back `is_current_turn`. +- **keepalive pool.** Per-replica in-memory map of parked live environments; 60 s idle + TTL, 300 s approval TTL; not storage. +- **mount.** A durable object-store prefix (`mounts//`) attached into + the sandbox; identified by a row in the `mounts` table. +- **nest.** The containment invariant of the Redis locks: alive ⊇ running ⊇ attached. +- **owner / replica.** The `owner` Redis key maps a session to the runner container + (`replica_id`) currently serving it; claimed without stealing. +- **park / warm resume.** Parking keeps a live harness process in the keepalive pool at a + turn boundary; a warm resume checks it out and continues in place. +- **record.** One event row in the `records` table (tracing database): a message, + thought, tool call or result, interaction request or response, usage, done, or error. +- **replica id.** Stable per-process id of one runner container + (`AGENTA_RUNNER_REPLICA_ID` or a minted uuid). +- **session-owned run.** A `/run` request carrying a `sessionId`; it survives client + disconnect, heartbeats, and persists records; a non-session run aborts on disconnect. +- **states (historical).** The dropped `session_states` table: a mutable per-session JSON + blob previously used for continuity and superseded by the turns ledger and the streams + header. +- **steer.** Command-matrix mode: force-cancel the current holder and start a new turn + with new inputs; lock-level only today. +- **stream id.** The uuid7 primary key of the `session_streams` row; returned by the + heartbeat and stored on every turns row. Despite the name, no frames flow through it. +- **transcript replay.** Continuation tier three: `session/new` plus the prior + conversation rendered as capped text in the prompt. +- **turn id.** Per-execution uuid lock value and correlator; distinct from `turn_index`, + the per-session completed-turn counter. +- **verdict.** The approve/deny outcome of a gate, stored in + `session_interactions.data.resolution` and as an `interaction_response` record; distinct + from the row's lifecycle `status`. diff --git a/docs/design/agent-workflows/projects/sessions-takeover/arda-branch-reconciliation.md b/docs/design/agent-workflows/projects/sessions-takeover/arda-branch-reconciliation.md new file mode 100644 index 0000000000..6e43f6095d --- /dev/null +++ b/docs/design/agent-workflows/projects/sessions-takeover/arda-branch-reconciliation.md @@ -0,0 +1,362 @@ +# Reconciling Arda's approval-UI branch with the #5382 approvals fix train + +Written 2026-07-21. All claims below are verified against the git history in this +repository: `origin/fe-enhance/approval-ui-onbig` (Arda's branch, 11 commits on top of the +current `origin/main` at `dd19a601e9`, which is v0.105.7), its safety copy +`origin/backup/approval-ui-pre-stack`, and the open PR train `sessions-rebase/backend` +(PR #5375), `sessions-rebase/runner` (PR #5376), and `plan/concurrent-approvals` +(PR #5382). Conflict claims come from real `git merge-tree` trial merges, not from +eyeballing file lists. + +## Vocabulary used throughout + +- The **runner** is the Node/TypeScript sidecar under `services/runner/` that drives a + coding-agent harness inside a sandbox. A **harness** is the actual coding agent, either + Pi or Claude Code, speaking the ACP protocol. +- A **gate** is a human-approval request the harness raises before a policy-gated tool + runs. The playground shows a gate as an approval card. +- To **park** is to end the streamed turn while keeping the live harness session alive in + a keep-alive pool, waiting for the human's answer. A **warm resume** checks that session + back out and answers the gate in place. The **cold path** rebuilds the environment and + replays or continues the conversation instead. +- A **sentinel** is a bookkeeping string the runner writes as a tool result when the real + result is unavailable. Two matter here: `DEFERRED_NOT_EXECUTED` (the call never ran this + turn because the turn paused on another gate) and `APPROVED_EXECUTION_RESULT_UNKNOWN` + (the call was approved and started, but the pause ended the turn before its result was + observed; the second sentinel exists only on the #5382 side). +- **Hydration** is the frontend rebuilding the conversation from the persisted session + records on reload, in `transcriptToMessages.ts`. +- On `main` today the runner can park exactly **one** gate. A turn with more than one gate + bails out of parking entirely (`multi-gate-no-park` in `server.ts`) and falls to the + cold path. Both bodies of work below independently generalize this to many gates, and + that shared ambition is where they collide. + +## Part 1: what Arda built, told as a story + +Arda's branch is really three projects that happen to live on one branch. + +The first project is **approval throughput on the runner**. He observed that when the +model fires several tool calls in parallel, each needing approval, the runner parks on the +first gate and force-settles the siblings with the `DEFERRED_NOT_EXECUTED` sentinel, so a +two-file write costs the user one full park-and-resume round trip per file, and on cold +replay the sentinel rendered as `[tool error: ...]`, which the model read as a denial and +gave up. His answer has two halves. On the cold path, render the deferred sibling as a +"call it again with the same arguments" nudge so the model re-issues the call and its own +gate surfaces (commit `fcc7348156`). On the warm path, stop parking on the first gate: +collect the whole staggered batch behind a debounce window (a new +`AGENTA_RUNNER_APPROVAL_COLLECT_MS` environment variable, default 800 ms), park once with +every gate recorded in a new `env.parkedApprovals` array, and resume only when the +frontend has answered every gate in the batch (commit `23b9557fef`, plus the formatting +commit `a7524a835c`). + +While testing that flow he hit the same reload corruption our incident investigation hit, +and fixed it from his side: a paused turn persists a terminal `done` record and the resume +run re-persists the same user prompt, so a reloaded conversation splits the parked gate +from the resume that settles it and duplicates the user turn. His runner fix tags the +paused `done` with its stop reason and skips re-persisting the prompt on a resume +(commit `e658c1ec43`); his frontend fix keys tool parts transcript-wide during hydration +and keeps the draft message open across a paused `done` (commit `3e5c2d1c44`). + +The second project is **approval friction and config UX**, and it is the part with his +name on it in spirit: an "Always allow this tool for this agent" toggle on the approval +card that writes a real permission into the draft agent config (per-tool `permission` for +gateway and custom-function tools, a `harness.permissions.allow` rule for harness builtins +like bash), with a contained Undo notice in the config pane (commit `14e82e03c7`); an +"Approve all" action that resolves a whole batch of cards in one step instead of walking +1-of-N; and a set of config-pane primitives that surface "what changed" inline: a shared +`HeightCollapse` animation primitive (commit `491c593986`), `ChangedPathsContext` and +`FocusPathsContext` plus a section-change classifier (commit `0f1448f68a`), and the +drawer-backed "Model & harness" and "Advanced" sections showing a missing provider key or +uncommitted changes inline with revert affordances (commit `9ab4099cb8`). + +The third project is housekeeping: a one-line drawer width fix (commit `8bdd5c4ed4`) and +the Claude Code harness environment variables and volume mount for the EE dev compose +stack (commit `07c9153a62`). + +The branch name suffix "onbig" and the backup branch tell the provenance: he originally +built this on the big-agents workspace trunk interleaved with his Drive work (which has +since merged to main through PRs #5399 and #5400), then ported the eleven surviving +commits onto clean v0.105.7 main. Nothing in the branch touches the sessions backend, the +`interaction_response` event, or any API code: it is a pure runner-plus-frontend branch, +and it merges onto main today without help from any open PR. + +## Part 2: the commit map + +Classification key: (a) approval-machinery overlap with #5382, (b) approval UX net-new, +(c) config-section UI work unrelated to approvals, (d) infrastructure, (e) parked/resume +defect fixes overlapping #5382's defect fixes. + +| # | Commit | What it does | Main files | Class | Merges over #5382 stack? | +|---|--------|--------------|-----------|-------|--------------------------| +| 1 | `fcc7348156` fix(runner): render a deferred parallel-tool sibling as a retry nudge | Cold-replay transcript renders the `DEFERRED_NOT_EXECUTED` sentinel as a "call it again" nudge instead of an error the model reads as a denial. Exports `isDeferredNotExecuted`. | `transcript.ts`, `responder.ts`, tests | (e), mostly complementary | Near-clean; `transcript.ts` is untouched by #5382, one small conflict in `responder.ts` | +| 2 | `23b9557fef` feat(runner): hold parallel approval gates and resume them together | Generalizes the single parked gate to a `parkedApprovals` array; debounced collect-then-pause window (800 ms); resume answers the whole batch or goes cold. | `server.ts`, `run-turn.ts`, `pause.ts`, `acp-interactions.ts`, `runtime-contracts.ts`, `otel.ts`, tests | (a), direct machinery overlap | Conflicts across all six runner files | +| 3 | `491c593986` feat(ui): shared HeightCollapse + config-section animation primitives | One CSS collapse primitive; section shimmer and draft tones; adds the `motion` dependency to `@agenta/ui`. | `agenta-ui`, `AgentCommitNotice`, `RevealCollapse` | (c) | Clean | +| 4 | `0f1448f68a` feat(config): changed-path + focus primitives for config sections | `ChangedPathsContext`, `FocusPathsContext`, `RailField` opt-in, `sectionChanges` classifier, two new shared signal atoms. Includes unit tests. | `agenta-entity-ui`, `agenta-shared` | (c) | Clean | +| 5 | `9ab4099cb8` feat(config): context-driven config sections | Inline provider-key field, focus-filtered changed controls with revert, wired dot-paths for sandbox and harness permission controls. | `agenta-entity-ui` SchemaControls | (c) | Clean | +| 6 | `14e82e03c7` feat(agent-chat): always-allow + batch resolve | The always-allow toggle writing draft-config permissions via new `toolPermission` helpers (224 lines of tests); Approve-all; dock latch so a batch resolves in one visual step. | `ApprovalDock`, `useAlwaysAllowTool`, `toolPermission.ts`, notices | (b) | Clean | +| 7 | `8bdd5c4ed4` fix(ui): TriggerDeliveriesDrawer width | One line. | `TriggerDeliveriesDrawer.tsx` | (c) | Clean | +| 8 | `e658c1ec43` fix(runner): don't corrupt a parked+resumed turn's persisted transcript | Tags the paused turn's persisted `done` with `stopReason`; skips re-persisting the prompt on a resume (`tailIsFreshUserMessage`). | `server.ts`, `otel.ts`, `run-turn.ts`, tests | (e), overlaps #5382's deferred record-hygiene item | Conflicts in `server.ts` and `otel.ts` | +| 9 | `3e5c2d1c44` fix(agent-chat): restore a parked+resumed approval on reload | Hydration keys tool parts transcript-wide; dedupes the resume's re-emitted `tool_call`; a paused `done` no longer closes the draft message. | `transcriptToMessages.ts`, tests | (a)/(e), overlaps #5382's hydration work | Conflicts (content plus add/add on the test file) | +| 10 | `07c9153a62` chore(hosting): Claude Code harness config in EE dev compose | `CLAUDE_CONFIG_DIR`, `CLAUDE_CODE_OAUTH_TOKEN`, and a writable `~/.agenta-claude-config` mount on the dev runner. | `docker-compose.dev.yml` | (d) | Clean (auto-merges) | +| 11 | `a7524a835c` style(runner): prettier-format the rebased parallel-approval port | Formatting fallout of the port. | 3 runner files | (a) rider | Folds into the machinery decision | + +Trial-merge ground truth (`git merge-tree --write-tree`): merging Arda's branch over the +full #5382 stack conflicts in exactly ten files, all of them the runner machinery and the +hydration adapter (the six `services/runner/src` files, the keep-alive approval test, and +`transcriptToMessages.ts` plus its test). Every other file in the branch, meaning the +whole config-UX, always-allow, drawer, and compose set, auto-merges. Against the sessions +rebase alone (#5375 and #5376 without #5382) the conflict set shrinks to two files, +`run-turn.ts` and `runtime-contracts.ts`. + +## Part 3: the overlap analysis + +There are four overlapping pairs. For each: what defect it addresses, whether the two +sides conflict textually and semantically, and which is more complete, judged against the +incident root-cause reports +(`docs/design/agent-workflows/scratch/debug-concurrent-approvals-db58551b.md` and +`debug-frontend-approval-dispatch.md`) and each side's tests. + +### Pair 1: multi-gate parking machinery + +**His:** `23b9557fef` "hold parallel approval gates and resume them together". +**Ours:** the #5382 stack's park-and-resume train (`b831e753f3` multiple simultaneous +requests, `1171dd5beb` record every parked gate, `a05238576a` partial answer sets, +`24bc93b344` the Pi-batching park rule, `3204ad5517` review closure). + +Both sides generalize main's single `env.parkedApproval` to a collection, in the same six +files. The textual conflict is total. The semantic conflict is the important one, and it +is a genuine contract difference: + +- **Arda's resume is all-or-nothing.** His `server.ts` comment states the contract + plainly: "The FE only resumes once it has answered ALL pending gates ... a gate still + missing a decision means the request is not that resume ... treat as a mismatch, go + cold." A resume request that answers only some parked gates evicts the live session. + This is exactly the shape the incident report identifies as defect 1's enabling + condition: the frontend's "every card settled" precondition could never hold after a + state rebuild, so the last answer sat unsent in browser memory and the conversation + died. Arda's branch does not change the frontend dispatch predicate + (`agentApprovalResume.ts` is untouched), so the all-settled batching survives on his + branch, and his design leans on it. +- **#5382's resume is per-card with partial answer sets.** One click dispatches one + answer; the runner answers the subset it received, streams those results, and re-parks + on the remaining gates. This was adversarially reviewed, carries an end-to-end + regression replay of the incident (`b955011782`), and was verified in four live QA + cycles. +- **Arda's collect window is the genuinely new idea.** #5382 parks each gate as it + arrives and explicitly deferred "two cards genuinely on screen at once" to issue #5391 + because the harness adapters raise gates serially. Arda measured gates arriving roughly + half a second apart and debounces the pause so a staggered batch parks together and the + user sees N cards at once. One caution that decides the window's fate: the live incident + investigation established that BOTH harness adapters raise approval requests strictly + serially today, each blocking until answered (Pi's confirms, and Claude's adapter per the + evidence recorded on issue #5391). The second request does not exist until the first is + answered, so no window of any length has a burst to gather on either harness. The window + therefore only becomes useful when the upstream adapter work in #5391 makes requests + arrive together, and it belongs with that issue rather than in the port. + +What Arda's machinery does not cover, and #5382 does, mapping to the incident defects: +defect 2 (the post-pause sweep stamps the retry-inviting sentinel onto an approved, +mid-execution call; #5382 excludes approved-executing calls and introduced the +`APPROVED_EXECUTION_RESULT_UNKNOWN` sentinel; Arda's sweep exclusion is still only the +paused gates, and his branch has no second sentinel), defect 3 (a never-started sibling +recorded as a successful "(no output)" result; #5382 buffers completion frames during the +pause; Arda does not touch `runtime-policy.ts`), defect 4 (the answer half of a gate is +never persisted; #5382 emits a durable `interaction_response` and writes the verdict onto +the interaction row through the API; Arda's branch contains no `interaction_response` +producer at all), and the Pi-batching deadlock (#5382 parks immediately when Pi's +batching blocks an approved call; Arda's resume waits on the blocked prompt). + +There is also an interaction between defect 2 and Arda's own retry nudge that matters: +because his branch leaves the sweep unfixed, an approved call that actually executed can +still be stamped `DEFERRED_NOT_EXECUTED`, and his nudge then tells the model to run it +again with the same arguments. For a side-effecting command that is a double-execution +invitation, sharpened rather than softened. + +**Verdict: resolve in favor of #5382's machinery.** It fixes four root-caused defects his +does not, its contract survives state rebuilds, and it is regression-tested against the +literal incident. The one thing worth carrying forward from his commit is the +collect-then-pause window, which is small (a `schedulePause` debounce on the pause +controller plus wiring in `acp-interactions.ts`) and can be re-expressed on top of +#5382's parked-gates map as a follow-up. It is, in effect, the first half of issue #5391 +delivered without upstream adapter changes, for the Claude harness only. + +### Pair 2: reload hydration of a parked-and-resumed approval + +**His:** `3e5c2d1c44`. **Ours:** the hydration halves of `20dcb553ce` and `297d82ae1f`. + +Here the two sides converged on the same core insight independently: tool parts must be +keyed transcript-wide, not per-draft-message, because a gate parked in one run is settled +by a `tool_result` in the resume run, and per-draft keying drops that result and leaves +the card stuck at "Awaiting approval". Both implementations hoist the tool index out of +the draft. On top of that shared core: + +- #5382 additionally overlays the persisted `interaction_response` answers onto requests + (so an answered card rehydrates as answered even before any resume ran), reopens a + sentinel-only sealed card when a later approval request re-parks it (the defect-B fix + from the frontend report, with the sentinel prefixes mirrored as exported constants), + and keeps `done` as a message boundary, with a comment stating that choice: the index + settles across the boundary, and message-per-turn stays true. +- Arda additionally dedupes the resume's re-emitted `tool_call` record (update in place + rather than pushing a duplicate part) and treats a `stopReason: "paused"` `done` as a + non-boundary, keeping the paused turn and its resume in one assistant bubble. His + non-boundary reading requires his runner-side commit (pair 3) to have stamped the stop + reason; #5382's version needs no new persisted field. + +Textually they conflict (content conflict on the adapter, add/add on the test file). +Semantically they are two dialects of the same fix, but only #5382's covers the answered +half: without persisted answers, Arda's hydration still resurrects an answered-but-not- +yet-resumed gate as pending, which is the state that killed the incident conversation. + +**Verdict: #5382's version is the superset that matters; take Arda's `tool_call` dedupe +as a small follow-up.** The dedupe becomes genuinely necessary the moment record ids are +scoped per turn (the queued audit-hardening work makes re-emits append instead of +upserting in place), so it is worth keeping on the roadmap with his name on it. The +one-bubble-versus-two rendering choice is cosmetic and can be decided later. + +### Pair 3: persisted-transcript corruption on park and resume + +**His:** `e658c1ec43`. **Ours:** the truthful-terminalization work in `4c8c809984` plus +the deliberate deferral in #5382. + +These do not solve the same defect, despite the similar titles. #5382's runner commit is +about result truthfulness (an approved executing call keeps its real result; a +never-started call becomes deferred, never a fake success). Arda's commit is about record +hygiene: the duplicated user-message row on every resume, and the paused `done` masquerading +as a turn boundary. That is, almost exactly, item 5 of the incident report's fix plan +("stop re-persisting the recovered prompt on approval resumes", part of making the record +a trustworthy audit log), which #5382's PR description explicitly deferred. So Arda +implemented, in parallel, a piece of our own declared follow-up. + +The catch is purely textual: his edit sits in the same `server.ts` persist block and the +same `otel.ts` `finish()` that #5382 reworked, so the commit does not apply as-is. The +content, a `tailIsFreshUserMessage` guard before persisting the prompt and an optional +`stopReason` on the terminal `done` record, is small and re-expressible on the stack in +an afternoon. One design note before re-applying the guard: the frontend report shows the +#5382 hydration currently relies on the duplicated user row's existence in one place (the +server-transcript-adoption heuristic prefers the server copy "whenever it has MORE +messages"), so removing the duplicate row should land together with a re-check of that +heuristic, which is a reason to do it as a deliberate follow-up rather than a mechanical +cherry-pick. + +**Verdict: no contest to resolve; adopt the intent as the already-planned record-hygiene +follow-up on top of #5382, re-implemented against the stack's code.** + +### Pair 4: rendering the deferred sibling on cold replay + +**His:** `fcc7348156`. **Ours:** the sentinel taxonomy in `4c8c809984` and `3204ad5517`. + +This is the cleanest pair: they compose. #5382 never touched `transcript.ts`, the +cold-replay prompt builder, so his change (render a `DEFERRED_NOT_EXECUTED` block as a +neutral "this was skipped, not denied; call it again" nudge instead of an error string) +fills a real gap that exists on the #5382 stack too: any deferred sibling that does end +up on the cold path today still replays as `[tool error: ...]` and reads as a denial. The +only overlap is a trivial conflict in `responder.ts` where he exports the existing +`isDeferredNotExecuted` helper and #5382 edited neighboring lines. + +Two conditions for taking it: it must land on top of #5382's sweep fix, because only +there is the deferred sentinel guaranteed to mean "genuinely never started" (see the +double-execution note under pair 1); and it should extend the same courtesy to the +`APPROVED_EXECUTION_RESULT_UNKNOWN` sentinel, which his branch does not know about, with +the opposite instruction (do not retry a side-effecting call). + +**Verdict: take it, rebased onto the stack, with the UNKNOWN-sentinel case added.** + +## Part 4: the extraction plan + +The branch splits cleanly along the trial-merge conflict line. Everything below is +ordered so that no step waits on anything it does not truly need. + +**Now, independent of every open PR (target: main).** + +1. `8bdd5c4ed4` (drawer width) and `07c9153a62` (compose harness config). Trivial, + zero-risk, one small PR or folded into the next one. +2. The config-UX train as one stacked pair of PRs in this order: `491c593986` + (HeightCollapse and section primitives), then `0f1448f68a` (changed-path and focus + primitives), then `9ab4099cb8` (the context-driven sections). These three auto-merge + over both main and the #5382 stack and have their own unit tests + (`sectionChanges`, `formatCommitted`). Nothing approval-related blocks them. +3. `14e82e03c7` (always-allow plus batch resolve) can also go to main now on top of the + config train (it needs `HeightCollapse` and the shared signal atoms). It is frontend + plus config only and auto-merges over the stack. If it lands before #5382, give it one + QA pass again after #5382 merges: the dock's batch latch was written against the + all-settled dispatch, and under #5382's per-card dispatch an "Approve all" click fires + several responses that may resume-then-re-park in waves. The runner side accepts + partial answer sets by design, so this should compose, but it is the one seam nobody + has watched live. + +**After #5375, #5376, and #5382 merge (target: the merged main).** + +4. Drop, from Arda's branch, the four machinery commits as they stand: `23b9557fef`, + `e658c1ec43`, `3e5c2d1c44`, and `a7524a835c`. Their tests encode the all-or-nothing + resume contract and go with them. +5. Re-express the salvage as three small follow-ups, each cheap on top of the stack's + code, ideally by Arda so the authorship follows the ideas: + - the collect-then-pause window (`schedulePause` on the pause controller, the + `onScheduleApprovalPause` seam, the env var), now feeding #5382's parked-gates map, + positioned honestly as a Claude-harness batching feature and the first half of #5391; + - the record-hygiene pair (skip the duplicate user row via `tailIsFreshUserMessage`, + stamp `stopReason` on the paused `done`), landed together with a re-check of the + frontend's server-transcript-adoption heuristic, as the already-planned deferred + item 5; + - the cold-replay retry nudge in `transcript.ts`, extended to also treat the UNKNOWN + sentinel (as "do not retry"), plus the trivial `isDeferredNotExecuted` export. +6. The `tool_call` re-emit dedupe in hydration rides along whenever the per-turn + record-id scoping lands, since that change is what makes it necessary. + +## Part 5: the decision list for Mahmoud + +**Decision 1: which multi-gate machinery survives.** +Context: both bodies of work replace main's single-gate park with multi-gate machinery in +the same six runner files; the contracts are incompatible (all-or-nothing batch resume +versus per-card dispatch with partial answer sets and re-park). +Option 1: keep #5382's machinery and drop Arda's four machinery commits. +Option 2: keep Arda's and rework #5382 on top of it. +Consequence: option 2 would discard the fixes for incident defects 2, 3, and 4 (sweep +clobbering, phantom success, unpersisted answers), the Pi-batching deadlock fix, the +adversarial-review closures, and the end-to-end incident regression test, and would +reinstate the all-answers-required resume that caused the dead conversation. +Recommendation: option 1, without reservation. Arda's implementation is competent but it +was built without the incident evidence; nothing in it handles a case #5382 misses, while +#5382 handles four cases his misses. + +**Decision 2: whether the collect-then-pause window becomes a follow-up.** +Context: the window is Arda's genuinely novel contribution; it batches staggered gates so +the user sees N cards and answers once, which #5382 deferred to issue #5391. It helps the +Claude harness only, because Pi raises confirms strictly serially. +Option 1: have Arda re-implement it on top of the merged stack as a small, env-gated +follow-up, QA'd against the Claude harness, framed as the no-upstream-change half of #5391. +Option 2: drop it until #5391 resolves the adapter-serialization question for both +harnesses. +Consequence: option 2 leaves multi-file Claude approvals at one round trip per file for +however long #5391 takes. +Recommendation: option 1. It is small, additive on the surviving machinery, and it keeps +Arda's headline idea alive with his authorship. + +**Decision 3: whether Arda's UX and config commits land now or wait for the stack.** +Context: the always-allow toggle, batch resolve, config sections, drawer fix, and compose +config all auto-merge over both main and the #5382 stack; only the always-allow and +batch-resolve dock has any behavioral coupling to the approval flow. +Option 1: land them on main now (config train and housekeeping immediately; the approval +dock commit too, with a repeat QA pass after #5382 merges). +Option 2: hold everything until #5375/#5376/#5382 merge and rebase once. +Consequence: option 2 costs Arda one to two weeks of idle divergence for no correctness +gain; option 1 costs one extra QA pass on the dock. +Recommendation: option 1. This is what actually unblocks him this week. + +**Decision 4: who owns the record-hygiene and retry-nudge follow-ups.** +Context: Arda independently built two things #5382 explicitly deferred or missed (the +duplicate-user-row and paused-done tagging; the cold-replay nudge). Both need +re-implementation against the stack's code rather than cherry-picks. +Option 1: assign both to Arda as his first post-merge tasks on the approvals surface. +Option 2: fold them into the existing audit-hardening queue on our side. +Consequence: either works technically; option 1 turns the collision into shared ownership +of the surface and gives his parallel work a landing. +Recommendation: option 1, with the two guardrails named in part 3 (record-hygiene lands +with the frontend adoption-heuristic re-check; the nudge lands only on top of the sweep +fix and covers the UNKNOWN sentinel too). + +**The dependency answer, for completeness.** Arda's branch is standalone on v0.105.7 +main: it does not depend on #5375, #5376, or #5382, uses no sessions API, and merges to +main today. The dependency runs the other way: if the train merges first, ten files +conflict, all of them the machinery this document recommends dropping, and everything +else rebases clean. diff --git a/docs/design/agent-workflows/projects/sessions-takeover/arda-handoff.md b/docs/design/agent-workflows/projects/sessions-takeover/arda-handoff.md new file mode 100644 index 0000000000..dbba885671 --- /dev/null +++ b/docs/design/agent-workflows/projects/sessions-takeover/arda-handoff.md @@ -0,0 +1,187 @@ +# Handoff to Arda: the sessions branch, your work, and what comes next + +Arda, this document hands you the sessions and approvals work. It explains what changed in +the codebase over the last few days, what happened to each part of your branch, the exact +setup you will work in, your task list, and the one question Mahmoud wants your proposal +on. Every decision in here names its reason and links the evidence, so you can check any +claim yourself. Mahmoud made all the decisions. You should be able to start within an hour +of reading, without a meeting. + +## The vocabulary this document uses + +The **runner** is the TypeScript service under `services/runner/` that executes agent +turns inside sandboxes. The coding agent itself (Pi or Claude Code) is called the +**harness**. A **gate** is a permission question the harness asks before running a +protected tool; the playground shows a gate as an approval card. To **park** a gate means +the runner ends the streamed turn but keeps the live harness process waiting in memory for +the answer. **Hydration** is the frontend rebuilding a conversation from the saved records +when you reload the page. A **sentinel** is a bookkeeping string the runner writes as a +tool result when the real result is not available; two exist: +`DEFERRED_NOT_EXECUTED` (the call never ran, retrying is safe) and +`APPROVED_EXECUTION_RESULT_UNKNOWN` (the call was approved and may have run, retrying is +not safe). + +## What changed in the codebase, in order + +On July 19, a live test of two parallel approval-gated shell commands failed badly: the +first approved command was reported as never executed, the conversation went silent after +the second approval, the answered card flipped back to "waiting" on reload, and a +follow-up message was ignored. The investigation found four separate defects. The full +reconstruction is in `debug-concurrent-approvals-db58551b.md` in the neighboring +`scratch` directory; it is the evidence behind several decisions below. + +A fix train rebuilt the approval machinery: the runner now holds several parked gates at +once, each answer is delivered the moment the user clicks it (a partial answer resumes the +turn and re-parks on the remaining gates), every answer is saved durably, and reloaded +conversations rebuild correctly. Four live QA cycles verified the exact failing scenario +end to end. + +In parallel, JP's session storage rework landed: the old mutable `session_states` blob is +gone, replaced by an append-only per-turn ledger (`session_turns`) plus a per-session +status row (`session_streams`, which also now holds the session's name and description). +JP is away, so Mahmoud took ownership, combined JP's two PRs into one branch, and added +several amendments after live verification. The architecture of all the session storage, +verified against the code and real database rows, is in `architecture.md` in this folder. +Read it before touching anything session-related; it will save you days. + +All of this now lives on one branch, `feat/sessions-storage-rework`, tracked by PR #5436. +Your five frontend commits merged into it today via PR #5438. + +## What happened to each part of your branch + +Your branch `fe-enhance/approval-ui-onbig` (PR #5426) contained eleven commits doing three +different jobs. Here is the outcome for every one of them, with the reasoning. + +**Your five frontend commits shipped, unchanged, with your authorship.** The +config-section animation primitives, the changed-path and focus primitives, the +context-driven config sections with the inline provider key, the always-allow and +approve-all approval UX, and the drawer sizing fix. They were rebased onto the sessions +branch and merged today (PR #5438). + +**Your four approval-machinery commits were not taken, and here is exactly why.** You and +the fix train independently rebuilt the same multi-gate parking, in the same six runner +files, with incompatible rules. Your version resumes only when every open question has an +answer. That all-or-nothing rule is precisely the behavior whose failure caused the July +19 incident: one unanswerable card silenced the whole conversation. Your version also +predates the four incident fixes (the cleanup that overwrote an approved command's result, +the fake success on a never-started command, answers never being saved, and a deadlock +with Pi's batch execution). The comparison found no case your version handles that the +merged version misses, and four cases the other way. The full code-level comparison is in +`arda-branch-reconciliation.md` in this folder. None of this is a comment on the quality +of your code; you built without the incident evidence, and your version was competent. + +**Your collect-window idea is preserved, under your name, for later.** The 800 millisecond +window that batches near-simultaneous gates into one screen of cards cannot help today, +because both harness adapters currently raise gates strictly one at a time, each blocking +until answered, so there is never a burst to collect. The idea is recorded on issue #5391 +as the client half to build when the upstream adapter work makes gates arrive together. + +**Two of your ideas were adopted as designs and are now your first tasks.** Your +transcript-hygiene fixes and your deferred-command hint solve real problems the fix train +explicitly postponed. They need rebuilding against the merged code rather than +cherry-picking, because the files they touch changed underneath them. Details in the task +list below. + +**Your compose-file commit was dropped in favor of the house pattern.** The Claude harness +login configuration you added to the tracked `docker-compose.dev.yml` belongs in the +gitignored per-machine override files (`docker-compose.dev..local.yml`), which +`run.sh` includes automatically. The user-facing setup is documented in +`docs/docs/self-host/agents/01-use-your-own-subscription.mdx`. If your dev box needs the +Claude harness, put those lines in a local override file. + +## The setup you work in + +You work directly on the branch `feat/sessions-storage-rework`, with plain git. No +GitButler is involved anywhere in your workflow. + +PR #5436 is the living pull request for this branch. It already carries the storage +rework, the approval fixes, and your five commits; your new work becomes new commits on +the same branch. The PR merges to main only when the sessions work is complete, and the +database migrations ship with that merge. Until then the migration files on this branch +may still be edited in place, because no released install has run them. + +Two promises protect you. First, nobody rewrites this branch's history: every change from +anyone lands as a new commit on top. Second, if rewriting ever becomes unavoidable, we +agree on the moment with you before it happens. Your old branch +`fe-enhance/approval-ui-onbig` stays on origin untouched, as the archive of your original +version. + +## Your task list, in the suggested order + +**1. Re-test the approve-all button against the new dispatch rule.** Your approve-all was +written when the frontend sent answers only after every card looked settled. The merged +machinery sends each answer the moment it is clicked, and the runner re-parks on the +remaining gates. Nobody has watched approve-all drive that flow. Run the +two-parallel-gates scenario on the dev stack, press approve-all, and verify both commands +execute exactly once and the cards settle correctly. + +**2. Rebuild your transcript-hygiene pair on the merged code.** Your original commits made +two changes: stop saving a second copy of the user's message when a paused turn resumes, +and mark a paused turn's end differently from a finished turn's end in the records. Both +are wanted. One warning from the reconciliation: a reload check in the merged hydration +currently relies on that duplicated message row, so make the frontend change and the +runner change together and re-run the hydration tests. + +**3. Rebuild your deferred-command hint.** Your original commit showed a clear "this +command was skipped, ask again if needed" hint on commands that were deferred during an +approval pause. Rebuild it to cover both sentinels from the vocabulary section, with +opposite guidance: the deferred one may invite a retry, the unknown-result one must not, +because the command may already have run. + +**4. Wire session titles to the server.** Renaming a session today only writes to the +browser's local storage, so the name does not follow the user across devices. The server +side is already done on this branch: the streams row holds `name` and `description`, and a +dedicated rename endpoint exists (`PUT /sessions/streams/header`). Replace the local-only +title handling in the chat with reads and writes through that endpoint. + +**5. Build cancel, then steer.** Today a user cannot stop or redirect a running agent. The +signal plumbing already exists on this branch: a kill command collapses the session's +locks, and an interrupt flag travels to the running turn through the heartbeat. What is +missing is everything above the signal. For cancel, follow the shape documented in the Zed +study: stop the turn, answer every pending permission question as cancelled, wait for the +harness to settle, then accept new input. For steer, read the OpenCode study first: their +design treats a mid-turn message as durable data, a per-session inbox row marked "steer" +or "queue" that the loop picks up at safe boundaries, instead of a dispatch-time judgment +call. That pattern is the recommended shape for ours, because it makes "a new message can +never be swallowed by parked work" structural. Both studies are in this folder. + +Out of scope for now, by explicit ruling: session deletion, and live-following a turn that +is already running from a newly opened tab. + +## Decisions you inherit, already made + +The `session_streams` table keeps its name, even though it holds the session header rather +than streamed frames; the architecture document explains what it really is. Record rows +are scoped by execution id, so each execution of the same tool call keeps its own row. +Every approval gate now creates a durable interaction row even when the run has no +workflow reference; rows without a reference group under the session, and any inbox you +build must tolerate that. + +## The one question Mahmoud wants your proposal on + +What should rejecting one approval card do to the other pending cards? Today each card +answers only for itself. OpenCode ships the opposite policy: one rejection cancels every +pending card in the session, on the reasoning that a rejection means "stop what you are +doing" rather than "no to this one thing". That policy would also have turned the July 19 +incident into a clean stop instead of a dead conversation. This is a product-feel call on +your surface. Bring a proposal with the UX reasoning: keep per-card rejection, adopt the +cascade, or something between, for example cascading only when the rejection carries no +explanatory message. + +## What to read, and what each document gives you + +All in `docs/design/agent-workflows/projects/sessions-takeover/` on this branch: + +- `architecture.md` explains every place session data lives, who writes and reads each, + and the traps. Read this first; it is the map of the territory you now own. +- `arda-branch-reconciliation.md` holds the commit-by-commit comparison between your + branch and the fix train, with the code evidence behind every outcome above. +- `zed-acp-approvals-comparison.md` documents how Zed handles approvals and cancellation + over the same protocol we use; its cancellation section is your task 5 design source. +- `opencode-comparison.md` documents OpenCode's session and approval design; its steer + inbox and its replay-then-follow reading pattern are the sources for task 5 and for the + out-of-scope attach work if it ever returns. + +And in the neighboring `scratch` folder, `debug-concurrent-approvals-db58551b.md` is the +incident report that drove everything; read it when you want to know why any of the +approval machinery is shaped the way it is. diff --git a/docs/design/agent-workflows/projects/sessions-takeover/arda-reject-siblings-proposal.md b/docs/design/agent-workflows/projects/sessions-takeover/arda-reject-siblings-proposal.md new file mode 100644 index 0000000000..3f86a55f62 --- /dev/null +++ b/docs/design/agent-workflows/projects/sessions-takeover/arda-reject-siblings-proposal.md @@ -0,0 +1,192 @@ +# Proposal: what rejecting one approval card does to its siblings + +Mahmoud, this is my answer to the open question in the handoff (§6): when the user rejects +one approval card, what happens to the other pending cards. Short version: **keep per-card +rejection as the default, do not adopt an inferred cascade, and instead surface the scope +explicitly** — a per-card **Deny**, a turn-level **Stop**, and reject-with-message as +**steer**. The reasoning is below with the mechanics it rests on; every claim names a file +or a doc you can check, and I mark the one thing only a live test can settle. + +I reached this by mapping the runner/ACP gate machinery and pressure-testing the option +against real batches, then feasibility-checking the one assumption it depends on (that a +turn-level Stop can be *warm*, not a teardown). Two supporting investigations are summarised +in §4; both are commit-grounded on `plan/concurrent-approvals`. + +## 1. The question is really two questions + +The surface phrasing is "reject one card → all cards?", but the choice is set by two axes: + +- **What does "reject" mean** — "no to *this* action" (precise) or "stop what you're doing" + (turn-level)? OpenCode ties this to whether the rejection carries a message: a bare reject + cascades and halts the loop, a reject *with* a message becomes a `CorrectedError` fed back + to the model as guidance + (`opencode-comparison.md` §3, `permission/index.ts:121-139`). +- **Are the siblings the same kind of thing?** If a batch mixes a `bash` write and a Gmail + send, "reject one → deny all" throws away the send the user wanted. If the system cannot + tell related siblings from unrelated ones, an *inferred* cascade is a guess. + +The mechanics decide the second axis, so start there. + +## 2. The mechanics that constrain the answer + +Verified against the merged runner (post-#5382) on `plan/concurrent-approvals`: + +- **Siblings are real, not hypothetical.** Request *delivery* serializes (one + `onPermissionRequest` at a time), but **park mode keeps the session alive**, so the next + gated call's request lands a tick later and parks too — the pool holds a + `Map` of N open gates + (`run-turn.ts:226-234, 598-614`; `server.ts:688`). Pi makes this routine: it "prepares + every call in a parallel batch before it executes any of them" (`run-turn.ts:766-768`), + so a parallel-tool-call turn parks several cards at once. Claude/ACP serializes at + dispatch, so for Claude siblings appear mostly via carry-forward or mid-resume. +- **Siblings can be heterogeneous.** The parked map is keyed only by `toolCallId`; nothing + groups it by operation or class, and a batch can freely mix a Pi `bash` write with a + gateway/custom relay call (`run-turn.ts:610`). +- **There is no danger signal on the wire.** The only risk hints are `readOnlyHint` + (write vs read) and the resolved permission (`permission-plan.ts:22-38, 180-188`). The + runner cannot tell "these three are one logical operation" from "these two are unrelated." +- **Today is strictly per-card, and provably so.** Resume matches decisions by `toolCallId` + (`session-identity.ts:274-291`, "matches strictly by toolCallId… never by name+args"). + Denying one closes only that call as a declined frame (`markToolCallDenied` → + `tool-output-denied`, `protocol.ts:349-356`); the harness continues with the other tool + results and only re-parks if gates remain (`run-turn.ts:739`). A sibling with no decision + is carried forward and stays pending (`server.ts:705-708`). **Denying one card does + nothing to the others or to the turn.** +- **No cascade runtime exists.** The only "affect all at once" primitive is session + teardown (`/kill`), which bulk-*cancels* every open RPC and destroys the process + (`server.ts:1115-1162`); it is not a fan-out of denies and writes no per-gate "denied" + rows. + +The load-bearing consequence: **because siblings can be independent and the system has no way +to know, inferring "reject → stop all" from a single click guesses wrong exactly in the +case that matters — the user rejects the one dangerous write and silently loses the safe +sibling they wanted.** + +## 3. The proposal: make scope explicit — Deny / Stop / steer + +Do not encode a fixed sibling policy. Surface the scope and let intent drive it. + +### Deny (per card) — the default, the common case +"No to *this* action." Keeps the existing per-`toolCallId` behaviour: the denied call closes +as a decline, the harness continues, untouched siblings carry forward. It is precise, safe +for heterogeneous batches, and needs **no new runtime** — it is what ships today. + +### Stop (turn-level, shown only at ≥2 cards) — "stop what you're doing" +One action to halt the whole parked turn. This is the *explicit* form of the cascade — the +user chooses it; it is never inferred from a bare Deny. Two important properties, both +verified feasible in §4: + +- It must be **warm** (reuse the pooled process), not the `/kill` teardown, or it throws away + the warm-resume the sessions work exists to deliver. +- It ships in two steps. **v1 today, no runner work:** Stop = resume answering *every* open + gate as `reject`; this is already a warm, no-teardown end and the env reparks idle-warm + (`run-turn.ts:725`, `server.ts:712, 793`). It is a hair softer than a true cancel — the + model narrates a short close rather than halting silently. **v2 (handoff task 5):** the + true cancel — answer pending as `cancelled` + `session/cancel`, settle idle — for clean + "stopped" semantics. + +### Steer (reject-with-message) — "no, do it *differently*" +A rejection that carries text is not a sibling policy at all; it is guidance. Route it as +OpenCode does: the message becomes feedback to the model, not a bare denial +(`opencode-comparison.md` §3). This is the redirect case ("write to staging, not prod") and +belongs with cancel/steer (task 5), kept off the Deny/Stop axis so the two stay clean. + +**Net:** per-card Deny (default) + explicit Stop (warm) + steer (redirect). The thing we +deliberately do **not** build is an *inferred* cascade; if a "Deny all" shortcut is ever +wanted it should be an explicit control shown at ≥2 cards, never a side effect of one reject. + +## 4. Why this is cheap and safe — the two investigations + +**Sibling machinery** (summarised in §2). The substrate is already per-card and heterogeneous, +so Deny is native and Stop is the only added verb. + +**Warm-Stop feasibility.** The assumption Stop depends on — that a turn-level cancel can leave +the harness warm and resumable — checks out as **feasible but unbuilt**, and the substrate +already proves it: park mode *deliberately* ends a turn mid-stream without disposing the +session (`run-turn.ts:242`, "Keep the live session… skip the destroySession"), and resume +answers the gates on that same live process. That is warm-cancel's exact substrate, in +production use. Confirmed along the way: + +- Answering a **subset** of gates does **not** trip the resume-contract "mismatch → evict" + guard — that fires only on history-fingerprint drift or expired credentials + (`server.ts:722-739`). So per-card Deny is warm by construction. +- A warm "Stop = deny-all via resume" is reachable **today** with no new runtime. +- A *true* cancel needs one adapter detail resolved: `session/cancel` is currently welded to + full `destroySession`, so a clean turn-only cancel needs a `session/load` rebind or a small + adapter extension. Process/sandbox/connection survive either way. + +Pressure-testing the two-write incident batch confirms the surface: Deny the bad write, keep +the good one, or Stop both — the case an inferred cascade cannot express. + +## 5. The one thing only a live test can settle + +Whether a **Claude ACP** turn, after its pending permissions are answered `cancelled` and it +receives `session/cancel` *without* the daemon being torn down, actually settles to a clean +idle state and accepts the next `session/prompt` on the **same pooled process** — rather than +wedging or demanding a fresh session. Zed does exactly this and the adapter claims support, +but Agenta has never run this precise sequence (today it only ever answers with `once`/ +`reject`, and only emits `session/cancel` as the first step of full teardown). This is gated +behind task 5, so it does **not** block the surface or the v1 warm Stop; it is the acceptance +test for the true-cancel upgrade. + +## 6. Direct answers to the three options you posed + +- **Keep per-card rejection?** Yes — as the default (Deny). It is precise, native, and the + only choice that survives heterogeneous siblings. +- **Adopt the cascade?** No — not *inferred*. A rejection should never silently cancel + unrelated siblings. Offer the cascade only as an explicit, user-chosen **Stop** (or a + visible "Deny all" at ≥2 cards). +- **Something between (cascade only when the rejection carries no message)?** The message is + the right signal, but it points the other way: a message means **steer** (redirect the + model), not "stop all." Bare Deny = this card; explicit Stop = the turn. The message axis + is a third thing, not a cascade trigger. + +## 7. What I'd build, in order + +1. Nothing new for **Deny** — it already ships (per-card). +2. **Stop** at ≥2 cards, v1 = deny-all-via-resume (warm, no runner work); a dock-level control, + not per-card. +3. Upgrade **Stop** to true cancel with task 5, behind the live acceptance test in §5. +4. **Steer** (reject-with-message → model feedback) with task 5's cancel/steer work. + +Happy to spec the dock interaction (where Stop sits relative to the cards, the ≥2 gate, the +carried-forward "1 of N still needs you" state) once you're aligned on the model. + +## 8. Implementation findings (update — 2026-07-24) + +Built during task 5. Two claims above are now resolved by real code; one is revised. PRs: +warm Stop + steer UI (`#5477`), batch surfacing (`#5470`). + +- **Stop — §5 settled *positive*.** Warm Stop is built and live-verified: a turn-level + `session/cancel` that does **not** tear down the daemon ends the turn cleanly and the session + accepts the next `session/prompt` on the same process. The §5 acceptance test passes. Open + tool calls settle with an `INTERRUPTED_BY_USER` sentinel; continuity treats the cancel like a + pause (invalidate, don't advance the ledger). Hard-kill stays opt-in behind + `NEXT_PUBLIC_AGENT_CHAT_STOP_KILLS_SESSION`. +- **Steer — §3 revised: "message as model feedback" is NOT reachable pure-FE (or runner-as-is).** + On **both** harnesses (Claude *and* pi), a reject makes the model **continue the original + turn** and react to a bare "permission denied" first — retrying the blocked action (a new gate + that *traps* the note) or reasoning around it — *before* the note lands. Root cause, verified + at each hop: the ACP permission reply is a **closed outcome** (`selected`/`cancelled`) with no + text field, and `claude-agent-acp` hard-codes the model-facing denial text + (`{behavior:"deny", message:"User refused permission to run tool"}`) precisely because ACP + hands it nothing to put there. So a pure-FE steer can only send the note as a **follow-up + turn**, which flails. The redirect needs one of: + - **A — runner reject-and-redirect (ours):** reject + suppress the original-prompt + continuation (reuse the Stop cancel infra) + a fresh turn on the note; the rejected call + stays in history. No vendored changes; slightly weaker binding (fresh turn, not in-turn). + - **C — carry the note as the harness deny `message`:** the Claude SDK deny result *already* + accepts an arbitrary `message` — the text channel exists at the bottom of the stack, only + the ACP/sandbox-agent wire in the middle is closed. We already `patchedDependencies` + sandbox-agent / acp-http-client / pi-acp, so extending the wire is patch-viable (local + immediately; Daytona needs a snapshot rebake), plus a per-harness adapter patch + (`claude-agent-acp` for Claude, `pi-acp` + the sandbox extension for pi). Better semantics: + the model reads "no — do X instead because Z" in-turn, no flail. + + Both share the same FE→runner note-plumbing (the note is currently dropped at 4 hops). The + **steer UI is complete and shipped flag-gated** (`NEXT_PUBLIC_AGENT_CHAT_STEER`, off) in + `#5477`; **A-vs-C is the open call.** +- **Batch (≥2 cards) — a small runner change, not blocked by #5391.** Surfacing parked siblings + as live gates is a runner-policy change (hold them instead of force-deferring all but the + first to `DEFERRED_NOT_EXECUTED`); it composes with per-card resume and needs no #5391 adapter + work. FE built in `#5470`; the runner change is parked on the collect-window ruling. diff --git a/docs/design/agent-workflows/projects/sessions-takeover/opencode-comparison.md b/docs/design/agent-workflows/projects/sessions-takeover/opencode-comparison.md new file mode 100644 index 0000000000..b39177fcda --- /dev/null +++ b/docs/design/agent-workflows/projects/sessions-takeover/opencode-comparison.md @@ -0,0 +1,429 @@ +# Comparison of session storage and approvals in OpenCode and Agenta + +This report examines how OpenCode solves the problems our sessions-takeover week was about: +where conversations live, how a session continues after a restart, how a human approves a +tool call, what happens on cancel or a mid-turn message, and how several clients follow one +session. It is the sibling of the Zed study +(`docs/design/agent-workflows/scratch/zed-acp-approvals-comparison.md`) and reads against +our architecture document +(`docs/design/agent-workflows/projects/sessions-takeover/architecture.md`) and the +db58551b incident report +(`docs/design/agent-workflows/scratch/debug-concurrent-approvals-db58551b.md`). + +OpenCode citations are paths inside a clone of `https://github.com/sst/opencode` at commit +`cb562b2c6289` (2026-07-21). Agenta citations are paths in this repository. + +## 1. Orientation: what OpenCode is and how its division of labor differs from ours + +OpenCode is the open-source coding agent built by SST, written in TypeScript. It runs as a +local server process. The terminal UI, the web app, and the desktop app are all clients of +that server over HTTP plus one server-sent-events stream (SSE, a one-way HTTP stream of +JSON events). The server package is `packages/opencode`; the newer core is +`packages/core`; the wire schemas live in `packages/schema`; the SolidJS client that +powers web and desktop is `packages/app`. + +The architectural difference from Agenta that explains almost everything else in this +report: **OpenCode owns the whole agent loop in one process.** The server calls the model +provider itself through its own LLM layer, executes tools itself on the host filesystem, +and writes every step of the conversation into its own store. There is no harness. There +is no sandbox. There is no second copy of the conversation anywhere. + +Agenta drives external harnesses (Pi, Claude Code) over ACP inside sandboxes. The harness +owns the model-facing conversation in its own native session file; our platform keeps a +separate display transcript (records), a mapping ledger (turns), a liveness plane +(streams plus Redis), and a durable approval plane (interactions), spread across two +databases, Redis, and an object store. OpenCode has one SQLite file. + +One reading note: OpenCode currently contains two generations of internals. The shipped +path is the "v1" loop (`packages/opencode/src/session/prompt.ts`, the `SessionPrompt` +monolith). A "v2" rewrite lives in `packages/core/src/session/` with its own runner and +event family; its spec says the `session.next.*` schemas "remain experimental and +unshipped" (`specs/v2/session.md:173`). Both generations write through the same durable +event log described next, so the storage story below covers both; where behavior differs I +say which generation I am describing. + +## 2. Session storage + +### One SQLite file, written through an event log + +Everything durable lives in a single SQLite database at +`~/.local/share/opencode/opencode.db` (`packages/core/src/database/database.ts:46-54`, +data directory from `packages/core/src/global.ts:11`). An earlier generation stored +sessions as JSON files under a `storage/` directory with keys like +`session//.json` and `part//.json`; that module +still exists with the migrations that folded those files into the new layout +(`packages/opencode/src/storage/storage.ts:81-211`), but the tables are now the truth. + +The write path is event sourcing. An **aggregate** is the unit that owns an ordered event +history; here the aggregate is the session. Two tables implement it: `EventTable` holds +one row per durable event (id, aggregate id, per-aggregate sequence number, versioned +type, JSON payload) and `EventSequenceTable` holds the latest sequence per aggregate +(`packages/core/src/event/sql.ts`). Publishing a durable event runs one SQLite +transaction that: reads the latest sequence, assigns `seq = latest + 1`, runs every +registered **projector** (a function that updates a read-model table from the event) +inside that same transaction, and inserts the event row +(`packages/core/src/event.ts:236-353`). The projection can never disagree with the log +because they commit together. + +The same machinery gives them idempotent replay. Re-committing an event whose sequence +already exists succeeds silently if id, type, and payload match the stored row, and dies +with "Replay diverged" if they do not (`event.ts:262-290`). Sequences must be gapless +(`event.ts:294-302`), and an aggregate can carry an owner id so only one workspace may +extend a replicated history (`event.ts:254-260, 291-293`). This is the foundation of +their cross-machine sync: a client ships serialized events to another node, which replays +them into its own log (`packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts:35-60`). + +### What is durable and what is live-only + +The event family draws a deliberate line. Boundary events are durable and carry the full +value; streaming fragments are transient bus traffic. The schema says it in a comment: +"Stream fragments are live-only; Text.Ended is the replayable full-value boundary" +(`packages/schema/src/session-event.ts:208-218`). So `text.started`, `text.ended` (with +the complete text), `tool.called` (with full input), `tool.success`, `tool.failed`, +`step.started`, `step.ended`, `compaction.ended` are durable rows; `text.delta`, +`reasoning.delta`, `tool.input.delta` are published to live subscribers only and never +touch the log. The shipped v1 events follow the same split: `message.updated` and +`message.part.updated` are durable (`packages/schema/src/v1/session.ts:502-507`), while +`message.part.delta` is not (`v1/session.ts:632-641`). Our runner reaches the same shape +operationally by coalescing delta families before persisting +(`services/runner/src/sessions/persist.ts:160-329`); OpenCode makes it a schema-level +contract instead of a persistence-time optimization. + +### The projections + +The projectors maintain the read model (`packages/core/src/session/projector.ts`): + +- `session`: one row per session with title, slug, project id, parent id (subagent + sessions are child sessions), directory, agent, model, share URL, revert state, a + per-session permission ruleset override, archived timestamp, and running cost and token + totals that projectors increment as usage events land + (`packages/core/src/session/sql.ts:22-66`, `projector.ts:90-110`). +- `message` and `part` (v1): one row per message and per part, each a JSON blob keyed by + ULID-style ascending ids (`sql.ts:68-98`). +- `session_message` (v2): one row per message with a **unique `(session_id, seq)` + index**, so the projected conversation carries its durable event order + (`sql.ts:119-138`). +- `session_input` (v2): the prompt inbox, covered in section 4. + +There is no session-list JSON and no separate title store; the session row is the header, +and a `title` agent renames it asynchronously after the first turn +(`packages/opencode/src/session/prompt.ts:193-253`). + +### One store serves both the display and the model + +This is the answer to our "is the model-facing conversation a second store" question: no. +The same `message` and `part` rows the UI renders are translated into provider messages +right before each model call by `MessageV2.toModelMessages` +(`packages/opencode/src/session/message-v2.ts:290-374`). The translation handles the +awkward cases inline: a completed tool part becomes a tool result with truncated output; a +tool part still marked `pending` or `running` (which after a crash means the process died +mid-call) becomes a tool error reading "[Tool execution was interrupted]" so no dangling +`tool_use` block ever reaches Anthropic (`message-v2.ts:349-360`); reasoning survives only +while the model matches the one that produced it. Provider-native context caching is +handled by prompt-cache keys, not by preserving a native transcript. + +### Continuation after a restart + +Because the store is the conversation, restart continuation is trivial: there is nothing +to resume. Liveness is purely in-memory (`SessionStatus` is a Map, +`packages/opencode/src/session/status.ts:26-48`; the v2 coordinator's active set "is +runtime state and is empty after a process restart", `specs/v2/session.md:169`). The next +prompt reads projected history and runs. The only restart work is reconciliation of +half-open state: the v2 runner, before assembling a request, durably fails any tool still +projected as `running` from a previous process with "Tool execution interrupted" +(`packages/core/src/session/runner/llm.ts:119-139`, spec `specs/v2/session.md:50`), and +the v1 translation layer produces the interrupted-tool error shown above. Compare our +three-tier ladder (warm pool, cold native `session/load` via the turns ledger, transcript +replay, `architecture.md` section 3): their entire ladder collapses to "read your own +rows" because no external process holds a better copy. + +Two features fall out of owning the store that we do not have at all. **Fork** copies a +session's messages and parts up to a message id into a fresh session with new ids +(`packages/opencode/src/session/session.ts:693-734`). **Revert** stages a boundary +message, and committing it deletes every projected message and inbox row past that +boundary and resets the context epoch (`projector.ts:415-453`), giving "rewind the +conversation" as a first-class verb. + +## 3. The approval flow end to end + +### Rules first, humans second + +A **permission ruleset** is an ordered list of rules `{permission, pattern, action}` with +actions `allow`, `deny`, or `ask`; last matching rule wins and the default is `ask` +(`packages/opencode/src/permission/index.ts:28-38`). Rules come from the agent's config, +from a per-session override stored on the session row, and from rules approved earlier in +the instance. A tool that wants to act calls `ctx.ask` with the permission name, the +concrete patterns, and an `always` list of generalized patterns +(`packages/opencode/src/session/tools.ts:81-89`). The bash tool derives those generalized +patterns with a hand-built arity dictionary that knows `git checkout main` generalizes to +`git checkout *` but `npm run dev` generalizes to `npm run dev` and not `npm *` +(`packages/opencode/src/tool/shell.ts:270-286, 408-409`, +`packages/opencode/src/permission/arity.ts`). + +If every pattern evaluates to `allow`, the tool proceeds with no human involved. If any +evaluates to `deny`, the tool fails immediately with a denied error. Only `ask` reaches a +human (`permission/index.ts:67-107`). + +### The pending map and the one-shot answer + +An ask creates an in-memory entry: the request object plus an Effect `Deferred`, which is +a one-shot promise the asking tool fiber awaits (`permission/index.ts:18-25, 98-107`). +The entry goes into a Map keyed by request id, and a transient `permission.asked` event is +broadcast. The request carries `tool: {messageID, callID}`, so every client can anchor +the approval card on the exact tool call it gates (`tools.ts:86`). + +**Multiple simultaneous approvals are structurally free.** The pending store is a keyed +map; every concurrently executing tool call blocks its own fiber on its own deferred; the +UI holds a per-session sorted list of pending requests fed by asked and replied events +(`packages/app/src/context/global-sync/event-reducer.ts:330-357`). Nothing serializes +asks, and nothing anywhere waits for "all cards settled". + +The answer is one HTTP call naming one request id: +`POST /session/:sessionID/permissions/:permissionID` with reply `once`, `always`, or +`reject` (`packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:362-380`), +or the instance-level `POST /permission/:requestID` +(`handlers/permission.ts:16-38`). Reply semantics +(`permission/index.ts:109-167`): + +- `once` resolves that one deferred and the tool proceeds. +- `always` resolves it, appends the request's `always` patterns as allow rules to the + instance's approved set, and then walks the remaining pending map: any other pending + request in the same session now fully covered by the new rules is auto-approved and a + `permission.replied` event is emitted for it. Approving "always run git" settles the + three other git cards on screen in the same call. In v2 the saved rules become durable + per-project rows (`packages/core/src/permission/saved.ts`, + `packages/core/src/permission.ts:250-256`). +- `reject` fails that deferred and then **rejects every other pending request in the + session** (`permission/index.ts:129-139`; same in v2, `permission.ts:237-247`). Their + chosen policy is that one rejection means "stop what you are doing", and the loop halts + (a declined ask is treated as a user-initiated stop, not as model-visible tool output, + `packages/core/src/session/runner/llm.ts:144-150, 297-301`). +- `reject` with a message becomes a `CorrectedError` carrying the text, which flows back + to the model as feedback instead of a bare denial (`permission/index.ts:121-127`). + +### What is durable about an approval: nothing, on purpose + +The pending map is process memory. The `permission.asked` and `permission.replied` events +are defined without the `durable` option, so they never enter the event log +(`packages/schema/src/v1/permission.ts:61-70`, +`packages/schema/src/permission.ts:44-53`). A client that attaches mid-ask discovers +pending requests from `GET /permission` (`handlers/permission.ts:12-14`), which reads the +live map. If the server dies while an ask is pending, a finalizer fails every deferred +(`permission/index.ts:54-61`), the tool call errors, and the durable trace the next +reader sees is the tool part's terminal state, not an approval record. + +That is the deep contrast with our interactions plane. OpenCode can afford undurable +approvals because the asker, the executor, and the answer route all live in one process +and the turn simply stays open while the human thinks; the durable answer is the tool +call's own status transition, exactly the Zed shape. Agenta cannot: our gate and executor +are in different processes, the browser turn closes when we park, answers may arrive from +a webhook days later, and we owe tenants an audit trail of who approved what. Our +`session_interactions` rows with stored verdicts, and the `interaction_response` record +(#5382 lane), have no OpenCode equivalent because OpenCode does not have our problem. + +## 4. Cancel, steer, and multi-client attach + +### Cancel is a fiber interrupt with honest bookkeeping + +`POST /session/:id/abort` calls `SessionPrompt.cancel`, which interrupts the running +fiber (`handlers/session.ts:232-235`, `packages/opencode/src/session/run-state.ts:77-86`, +`packages/opencode/src/effect/runner.ts:171-201`). Because the loop is in-process, the +signal takes effect immediately; there is no heartbeat-granularity delay like our +30-second `is_current_turn` path (`architecture.md` gap 5). The cleanup then writes an +honest durable ending: every open tool call gets status `error`, error text "Tool +execution aborted", and `metadata.interrupted = true`, with its accumulated partial +output preserved in metadata (`packages/opencode/src/session/processor.ts:577-596`); the +assistant message is finalized with an aborted error +(`prompt.ts:1203-1211`). Notably, when an interrupted shell command produced output +before the abort, that partial output is replayed to the model as a tool result on the +next turn (`message-v2.ts:326-336`), so the model knows what actually happened. No +synthetic success is ever written, and no "retry the same call" sentinel exists. Their +cancel is everything our db58551b fix plan items 2 through 4 ask for. + +### Steer, shipped version: the loop re-reads the store + +In the shipped v1 path a new prompt during a running turn does not error and does not +queue in memory. `prompt()` writes the user message durably first +(`prompt.ts:1046-1047, 1052-1071`), then calls into the per-session runner, whose +`ensureRunning` joins the already-active run instead of starting a second one +(`effect/runner.ts:117-137`). The loop reloads the full projected history at the top of +every step (`prompt.ts:1092-1094`), and its exit condition is "the last assistant message +finished AND no user message is newer than it" (`prompt.ts:1111-1130`). A message that +landed mid-turn therefore steers the conversation at the next step boundary, and a +message that lands after the turn would have ended keeps the loop alive for another +round. There is no 409, no separate steer verb, and no way for new text to vanish into a +stale resume: the store is the queue. Our defect 6 (a text message during a park consumed +as an approval resume) cannot be expressed in this design. + +### Steer, v2 version: a durable prompt inbox + +The v2 slice makes the same idea explicit data. `session_input` is a durable inbox table; +every prompt is first **admitted** (durable `prompt.admitted` event, row with +`admitted_seq`) and later **promoted** (durable `prompted` event stamps `promoted_seq`, +and only then does the projector write the model-visible user message) +(`packages/core/src/session/sql.ts:140-166`, +`packages/core/src/session/input.ts:41-168`). Delivery is explicit per prompt +(`specs/v2/session.md:155-158`): + +- `steer` promotes at the next safe provider-turn boundary, including inside the current + drain; the runner computes a cutoff sequence and promotes all eligible steers in + admission order (`packages/core/src/session/runner/llm.ts:187-196`, + `input.ts:245-266`). +- `queue` waits until the session would otherwise go idle, then promotes exactly one + queued prompt FIFO (`input.ts:268-288`, `runner/llm.ts:383-406`). + +Interrupt stops execution but "preserves durable inbox rows for a later wake or resume" +(`specs/v2/session.md:22-27`), so cancel and steer compose: you can interrupt, and the +queued text is still there, admitted but unpromoted, visible to clients as queued input +(`specs/v2/session.md:35-37`). A per-process coordinator serializes execution per session +while letting sessions run concurrently, with joins and coalesced wakes +(`packages/core/src/session/run-coordinator.ts:5-15, 67-101`). + +### Attach: one event stream, snapshot endpoints, and a durable cursor + +Every client follows every session the same way: subscribe to `GET /event`, one SSE +stream carrying all bus events for the instance, then fetch snapshots (session list, +messages, pending permissions) through plain endpoints. The handler registers its +listener eagerly before the response body starts, with a comment explaining that events +published while the stream is starting cannot be lost +(`packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts:28-31`). The +client applies events onto its stores with a reducer and refetches snapshots when the +stream reconnects (`packages/app/src/context/server-sync.tsx:388-393`, +`packages/app/src/context/global-sync/event-reducer.ts`). Attaching mid-turn needs no +special verb: the snapshot contains the partially-built assistant message (parts are +durably updated at every boundary), and the stream delivers deltas from now on. Several +clients attach concurrently; nobody holds a watcher lock; the TUI, the web app, and the +desktop render the same turn live. + +The v1 stream has no replay cursor (reconnect means refetch). The v2 contract closes that +gap precisely: `sessions.events({sessionID, after})` replays durable events after an +aggregate sequence and then tails live commits, and the implementation registers the +wake signal **before** the historical read so the replay-to-tail handoff cannot miss a +commit (`packages/core/src/event.ts:565-604`, `specs/v2/session.md:175-183`). Live-only +deltas are deliberately excluded from the replayable stream and can never advance the +cursor. A finite paged variant exists at `GET /api/session/:id/history`. + +Two cross-machine features sit on top. **Share** pushes session, message, and part +payloads to a cloud viewer keyed by a share id and secret, batched from bus events +(`packages/opencode/src/share/share-next.ts`). **Sync** replays serialized durable events +onto another node's log with owner claims, which is how a session moves between a laptop +and their cloud workspace (`handlers/sync.ts:35-91`). + +## 5. Side by side with our planes and our gaps + +| Agenta plane | OpenCode counterpart | Note | +|---|---|---| +| Records (tracing DB, upsert log) | `EventTable` plus projections in one SQLite file | Theirs is also the model conversation; ours is display-only because the harness owns the model copy | +| Turns ledger (`session_turns`) | None; nearest is the per-session `seq` in `EventSequenceTable` | The ledger exists to map to a harness-native session id; with no harness there is nothing to map | +| Streams row + Redis nest | In-memory runner map and status events | Single node, one writer per session; clustered ownership is on their future list (`specs/v2/session.md:109, 185`) | +| Interactions (durable gates, verdicts) | In-memory pending map + `permission.list` | No durable approval, no webhook path, no audit | +| Mounts (object store + geesefs) | The host filesystem | Their bash "is not sandboxed" by design (`specs/v2/session.md:204`) | +| Keepalive pool (parked processes) | Nothing to park; the server is the process | The open turn plays the role of our warm park | + +What they avoid structurally: the whole continuation ladder (no native session file to +trust or invalidate), heartbeat-latency cancel (in-process interrupt), the +answered-cards-resurrect bug class (pending truth is queryable and tool status is the +durable answer), and the split between transcript and model context (one store, one +translation). + +What they lack that we have and need: multi-tenant projects and authorization, remote +sandboxed execution with credential isolation, harness choice (their loop is their only +agent; we host Pi and Claude Code), durable approvals with webhook delivery and +re-invocation references, an audit trail, ingest quotas, and any notion of a runner fleet. +Their single-file, single-process shape is the reason their session code is small; it is +also the reason it cannot be our architecture. + +Against our two open gaps: gap 5 (cancel and steer) is where their design is most +instructive, because both halves are data problems in their system, not signaling +problems: cancel is an immediate interrupt followed by honest durable settlement, and +steer is a durable inbox with explicit delivery semantics. Gap 6 (live mid-turn attach) +is solved by the combination we lack: prompt boundary persistence plus a per-session +durable cursor plus a replay-then-tail read path with the wake registered before replay. + +## 6. Learnings + +**Adopt.** + +1. A per-session monotonic sequence and a replay-then-tail read contract, for gap 6. Our + records plane already persists promptly but orders by ingest time and restarts + `record_index` per execution (`architecture.md` section 2). Adding a per-session + sequence at ingest, an `after` cursor on the records query, and a tail wired to the + ingest path gives exactly their `sessions.events` contract + (`packages/core/src/event.ts:585-604`). Their two details worth copying verbatim: + register the live subscription before the historical read so the handoff cannot drop + an event, and keep live-only deltas out of the replayable stream so a cursor always + equals a persisted row. +2. Steer and queue as durable inbox rows with admitted and promoted states, for gap 5 and + for db58551b defect 6. "Is this request an answer to the gates or new work" stops + being a dispatch heuristic and becomes a field: a prompt with `delivery: steer` + promotes at the next safe boundary, a `queue` prompt waits for idle, and an approval + answer is neither. Our runner's parked dispatch would then consume answers from the + interactions plane and prompts from the inbox, and could never again consume new text + as a stale resume (`packages/core/src/session/sql.ts:140-166`, + `specs/v2/session.md:155-171`). +3. Terminal-evidence discipline on tool calls, confirming the db58551b fix plan. Their + only writers of terminal tool state are real settlement, explicit abort cleanup that + marks calls interrupted while preserving partial output + (`processor.ts:577-596`), and a drain-start reconciliation that durably fails calls + left `running` by a dead process (`runner/llm.ts:119-139`). Nothing ever fabricates a + success, and the interrupted marker even feeds the partial output back to the model + (`message-v2.ts:326-336`). Our post-pause sweep should converge on exactly this set of + writers. + +**Adapt.** + +4. The durable-boundary versus live-delta split as a schema contract. We already coalesce + deltas at persist time; declaring per record type whether it is replayable (with the + full value) or ephemeral would let the attach endpoint and the audit story reason + about the log instead of about persistence behavior + (`packages/schema/src/session-event.ts:208-218`). +5. Approval generalization at answer time. Their `always` reply saves wildcard rules + derived per tool (the bash arity dictionary) and immediately settles other pending + cards the new rule covers (`permission/index.ts:145-166`, `shell.ts:408-409`). For us + this maps to an "always allow this command shape for this session or agent" option on + the approval card, resolved in the runner's policy layer; it needs adaptation because + our rules must be tenant-scoped and durable rather than instance memory. +6. Reject with a message as model feedback. Their reject can carry text that reaches the + model as a correction instead of a bare denial (`permission/index.ts:121-127`). ACP + permission outcomes can carry equivalent context; our deny path currently sends only + the verdict. +7. Their reject-cascade policy (one rejection cancels every pending gate in the session, + `permission/index.ts:129-139`) is a defensible product answer to partial answer sets: + rejection means stop. If we adopt it, it belongs in the runner's gate bookkeeping, and + it would have turned the db58551b partial-answer deadlock into a clean stop. + +**Does not transfer.** + +8. In-memory pending approvals. Holding the turn open on a process-lifetime deferred is + only safe when the asker, executor, and answer route share a process and the client + connection model tolerates long-open turns. Our gates outlive browser requests, + arrive from webhooks, and must survive runner restarts; the durable interactions plane + stays. +9. Event-plus-projection in one transaction. Their consistency guarantee depends on one + SQLite file owned by one process (`event.ts:236-353`). We are cross-database and + cross-process by necessity; our nearest equivalent remains idempotent upserts plus the + sequence from learning 1. +10. No liveness plane. They need no Redis nest because exactly one process can run a + session and clients never contend for execution. Our owner, alive, and running locks + exist because many runner replicas serve many tenants; their own spec lists + "clustered Session execution ownership and stale-runtime fencing" as unsolved future + work (`specs/v2/session.md:109, 185`), which is a useful reminder that our streams + plane is us already paying a cost they have merely deferred. + +## Verdict on the choices we just made + +Per-card dispatch is vindicated: their reply names one request id, one click delivers one +answer, and no code path anywhere waits for sibling cards +(`handlers/session.ts:362-380`). Persisting the answer half of a gate is vindicated in an +indirect way: their durable answer is the tool call's status transition, and every rebuilt +client reads settled state rather than reconstructing "waiting" from requests, which is +the same invariant our `interaction_response` record restores. The turns ledger is +neither vindicated nor contradicted: OpenCode simply has no harness-native session id to +map, which confirms the ledger is an artifact of driving external harnesses, not a +conversation primitive; the conversation primitive their design argues for is the +per-session durable sequence. Park and warm resume is the one place their design pushes +back: they never park because the open in-process turn is their warm state, and their v2 +direction is to make every resumable thing a durable row rather than a held process. Our +keepalive pool remains justified by sandbox and model-context reuse economics, but the +inbox learning says the dispatch decisions around a parked process should be driven by +durable data, not by what happens to be in the pool. diff --git a/docs/design/agent-workflows/scratch/debug-concurrent-approvals-db58551b.md b/docs/design/agent-workflows/scratch/debug-concurrent-approvals-db58551b.md new file mode 100644 index 0000000000..78cac39786 --- /dev/null +++ b/docs/design/agent-workflows/scratch/debug-concurrent-approvals-db58551b.md @@ -0,0 +1,208 @@ +# Root-cause report: concurrent human-approval failure (session db58551b) + +This report explains why Mahmoud's live QA of two parallel gated writes broke: the first +approved command was reported as "not executed", the conversation went dead after the second +approval, the first approval card flipped back to "waiting", and a follow-up message was +answered with "Done" instead of being read. Every claim below is verified against two +independent evidence sources: the persisted session dump +(`debug/session-db58551b.json`) and the runner container's timestamped logs +(`agenta-ee-dev-wp-b2-rendering-runner-1`, 09:51 to 09:56 UTC on 2026-07-19). Where the two +sources disagree, the logs win, because the session record turns out to rewrite itself (see +defect 5). + +## Orientation: the moving parts + +- **The runner** is the Node/TypeScript sidecar under `services/runner/`. It drives a + coding-agent harness (here Pi, whose tool calls carry OpenAI-style `call_...` ids) inside a + sandbox and streams events to the web UI. +- **A gate** is a human-approval request. When Pi wants to run a policy-gated builtin tool + (here `Bash`), our Pi extension's hook calls `ctx.ui.confirm` and waits + (`extensions/agenta.ts`, `piDialogAllows`). The hook is fail-closed: only an explicit + `true` lets the command run. The pi-acp bridge surfaces the confirm as an ACP + `session/request_permission`, which the runner classifies and shows to the user as an + approval card (`acp-interactions.ts`). +- **Park and warm resume.** When a gate has no answer, the runner ends the turn and "parks" + the live harness session in a keepalive pool (`server.ts`, `parkedApprovals`). When the + answer arrives, the runner checks the session back out and continues it in place; this is a + **warm resume** (`[keepalive] resume` in the logs). Every post-approval turn in this + session was a warm resume: tool-call ids are reused across turns, and the resumed turns + re-emit cached usage rather than making a new model call. +- **The cold path is native continuation, not replay.** On a keepalive miss the runner + builds a fresh environment, but the harness resumes its OWN session, located through the + harness session id the runner persists per turn + (`session-continuity-durable.ts`, `fetchLatestSessionTurn`). The model does not re-issue + tool calls. Replaying the conversation from our persisted event stream happens only as a + last-resort fallback when that continuity lookup fails. Neither cold tier was involved in + this incident. +- **The deferred-sibling sentinel.** `TOOL_NOT_EXECUTED_PAUSED` (`tracing/otel.ts:66`) is the + string `"DEFERRED_NOT_EXECUTED: paused for another approval; retry the same call if still + required."`. After a pause, `settleOpenToolCalls` (`tracing/otel.ts:1479`) stamps it onto + every still-open tool call not excluded by a predicate; the only exclusion today is "this + call is itself a paused gate" (`run-turn.ts:505`, predicate `pause.isPausedToolCall`). +- **Pi serializes confirms.** Throughout this session, Pi raised one `ctx.ui.confirm` at a + time. The second parallel call's confirm only surfaced after the first call's confirm was + answered, one park/resume cycle later. Two approval cards were therefore never truly + outstanding at the same moment on the runner; the user experienced them as one card per + cycle. This matches the adapter-serialization finding tracked in issue #5391. + +## The verified timeline + +The model issued two parallel `Bash` calls at 09:53:20: `IRll` (append a line to +`agent-files/README.md`) and `VIgq` (append the same line to `agent-files/NOTES.md`). + +1. **Turn 1 (09:53:20).** Pi raised the confirm for `IRll` only. The runner gated it (card + `f6f8384d`), parked, and the turn ended. `VIgq`'s confirm was never raised in this turn. + Twenty milliseconds after the park, a `tool_result` for `VIgq` was persisted with output + `"(no output)"` and `isError: false`: a successful-looking result for a command that had + not run and had never been approved. No gate, no execution, no bash activity for `VIgq` + appears anywhere in the logs for this turn. +2. **Turn 2 (09:53:22, warm resume after the user approved card 1).** The runner answered + `IRll`'s confirm with `once`. One millisecond later Pi raised the confirm for `VIgq`; the + runner gated it (card `b5f44eb8`) and parked again. The post-pause sweep then stamped + `TOOL_NOT_EXECUTED_PAUSED` onto `IRll`, the call the user had just approved, because the + sweep's exclusion list contains only paused calls and `IRll` was "approved and executing", + a state the sweep does not know about. `IRll`'s real completion arrived after the park and + was discarded (late events are dropped once the turn is cleared, + `session-events.ts:31`). The README append itself almost certainly executed on the + sandbox: the confirm resolved `true`, execution is never cancelled in park mode, and + nothing in the logs shows a failure. Only its report was destroyed. +3. **The dead hour of the session (09:53:22 to 09:54:17).** The user approved card 2 in the + UI. The logs show NO resume request arriving in this window. The click was recorded only + in the browser's local state. The frontend's auto-resume fires only when every approval + card on the last turn looks settled (`agentApprovalResume.ts:163`), and the re-rendered + state showed card 1 as pending again, so the auto-resume never fired. Card 1 looked + pending because nothing in the persisted record says a gate was ever answered (defect 4). +4. **Turn 3 (09:54:17, triggered by the user's text message).** The frontend bundled the + stored card-2 approval into the message request (`[keepalive] resume gates=1 approve=1`). + The runner used the request as an approval resume: it answered `VIgq`'s confirm, `VIgq` + executed (approved, at 09:54:17), and the model then continued the parked write task and + replied "Done - two separate write requests ran in parallel". The user's actual questions + were not answered; the turn was a resume of the stale task, not a reading of the new + message. + +Net effect on disk: both appends executed exactly once, each only after its approval. There +was NO unapproved execution in this session. What broke was reporting, state +reconstruction, and the resume trigger, plus a latent hazard described under defect 2. + +## Defects + +### Defect 1: the frontend never dispatches the resume for the last answered card + +The user's click on card 2 produced no network request. The decision sat in browser state +until the next message happened to carry it. Root cause: the auto-resume precondition +"every card settled" (`web/packages/agenta-playground/src/state/execution/agentApprovalResume.ts:163`) +can never hold after a state rebuild, because the persisted record contains the request half +of every gate and never the answer half (defect 4). This is what killed the conversation and +what made card 1 flip back to "waiting for approval". + +### Defect 2: the post-pause sweep clobbers an approved, executing call + +`run-turn.ts:505` sweeps every open call except currently-paused gates. A call that was just +approved on this resume and is mid-execution is not in that exclusion, so it gets stamped +`TOOL_NOT_EXECUTED_PAUSED` (`isError: true`) and its real result is dropped when it lands +late. Two consequences: the user sees their approved command reported as never executed, and +the sentinel's text invites the model to "retry the same call", which for a +side-effecting command that DID execute means a double execution. The model happened not to +retry in this session; nothing prevents it. The sweep predicate and the one-tick drain +(`pause.ts`, a single `setImmediate`) are pre-existing on `origin/main`; the #5382 lane's +warm re-park (commits `ab3c7819bb`, `0071f90090`) is what creates the state "approved and +executing while a new gate pauses the same turn" that exposes them. + +### Defect 3: a never-started sibling is recorded as a successful "(no output)" result + +In turn 1, `VIgq` had not started (its confirm was still queued inside Pi), yet a +`completed` frame closed it as a success. The frame-suppression policy deliberately lets +`completed` frames through during a pause so that a legitimately finishing auto-allowed +sibling keeps its real result (`runtime-policy.ts:45`); a cancellation-closure frame for a +never-started call takes the same path and is indistinguishable there. The result mapping +then records any `completed` frame as `isError: false` (`tracing/otel.ts:1445`). The exact +emitter of that closure frame (pi-acp's turn-cancellation path closing unstarted calls) is +the one link not pinned to a line; everything downstream of the frame is verified. The +correct record for that call at that moment is the deferred sentinel, not a success. + +### Defect 4: the answer half of a gate is never persisted + +The runner protocol has an `interaction_request` event and no answer event +(`protocol.ts:358`); a full-repo search finds no `interaction_response` producer. The +interactions table gets its row flipped to resolved (`interactions.ts:97`) but the payload +carries only lifecycle status, not the allow/deny verdict, even though the API schema has a +`resolution` field for it (`api/oss/src/core/sessions/interactions/dtos.py:24`). The live UI +tracks answers only in local memory (`AgentConversation.tsx:1031`); hydration reads only +session records (`loadSession.ts:20`) and reconstructs every persisted request as pending +(`transcriptToMessages.ts:144`). Every rebuild therefore resurrects answered gates. This is +the root that feeds defect 1, and it is also an audit gap: the record cannot say who +approved what. + +### Defect 5: the persisted session record rewrites itself + +Tool-result rows are stored under deterministic ids keyed by session, tool-call id, and +event type, with no turn scoping (`persist.ts:295`). A later result for the same call +overwrites the earlier row in place, preserving `created_at`. In this session, `VIgq`'s real +09:54:17 result silently replaced its turn-1 artifact row. The exported dump therefore +misrepresents history, which is how it initially supported a false "unapproved execution" +reading. Separately, an approval resume re-persists the recovered prior prompt as a fresh +user-message row (`server.ts:1004`), which is why "no i want to write requests in parallel" +appears twice in the record. The session record is currently not a trustworthy audit log. + +### Defect 6: a text message during a park is consumed as an approval resume + +A message request that arrives while gates are parked goes down the approval-resume branch +(`server.ts:663`). In this session it carried a bundled decision, matched, and warm-resumed +the stale task; the model completed the old work and never addressed the new text. Had it +carried no decision, the branch would have evicted the parked session and continued on the +cold path, with the same user-visible outcome (the stale task finishes, the question is +ignored). The +dispatch does not distinguish "this request answers the gates" from "this is new user text +that should supersede or queue behind the parked work". + +### Defect 7 (found in passing): session-turns append fails with HTTP 500 + +`[sandbox-agent] append HTTP 500 ... harness=pi_core turn=0` recurs throughout the logs on +this stack. The new append-only `session_turns` ingestion is failing for Pi sessions. This +is unrelated to approvals but means the new sessions plane is silently not recording these +turns. It needs its own investigation. + +## Fix plan, in order + +1. **Persist the answer half of every gate (fixes defects 4 and, through it, 1).** Emit an + answer event into the session record when a gate is resolved, and store the allow/deny + verdict in the interaction row's `resolution`. Hydration then overlays answers onto + requests, rebuilt state shows answered cards as answered, and the frontend's auto-resume + condition can become true. This is the smallest change that revives the dead conversation + and the flip-flopping cards. Add a frontend fallback so an answered card's decision is + dispatched even if other cards look unsettled, so one rendering bug can never strand a + decision in browser memory again. +2. **Protect approved, executing calls from the sweep, and let their real result land + (fixes defect 2).** Carry "approved this resume" ids into the pause controller's + exclusion set, and hold the turn's terminalization until those calls reach their own + terminal frame (bounded by the existing per-call time limit) so the real result replaces + nothing and the sentinel is never written onto them. The retry-inviting sentinel must + never be attached to a call whose execution actually started. +3. **Record a never-started sibling as deferred, not as success (fixes defect 3).** During a + pause, buffer `completed` frames for sibling calls until the drain settles which calls + gated; a closure frame for a call that never started becomes the deferred sentinel, a + genuine completion keeps its real result. +4. **Separate "answer the gates" from "new user text" in the parked dispatch (fixes defect + 6).** An incoming request with fresh text should either abandon the parked task cleanly or + queue the text as the next turn after the resume completes; it must never vanish into a + resume of stale work. This is partly a product decision and can land after 1 to 3. +5. **Make the session record append-only per turn (fixes defect 5).** Scope stable + record ids by turn so contradictory results append rather than overwrite, and stop + re-persisting the recovered prompt on approval resumes. Needed for trustworthy audits and + for debugging every future incident. +6. **Investigate the session-turns HTTP 500 (defect 7)** as its own thread against the new + sessions ingestion. +7. **Add the missing regression test.** The existing tests model gates that are all known + before the first pause. The real Pi shape is: two parallel gated calls, one gate raised, + approve, second gate surfaces during the warm resume while the first command is still + executing, approve, assert both side effects exactly once and both real results recorded. + +## Open questions + +- The precise emitter of the turn-1 closure frame for the unstarted call (pi-acp's + cancellation path is the strong candidate; confirming it pins defect 3's upstream half). +- Whether the frontend should also dispatch each answer immediately as it is clicked + (per-card dispatch) instead of batching until all cards settle; the Zed comparison report + (`zed-acp-approvals-comparison.md`, in progress) should inform this. +- Whether Pi can be configured or extended to raise multiple confirms concurrently, which is + the only path to two cards genuinely on screen at once (issue #5391). diff --git a/docs/design/agent-workflows/scratch/debug-session-turns-append-500.md b/docs/design/agent-workflows/scratch/debug-session-turns-append-500.md new file mode 100644 index 0000000000..9656916e2a --- /dev/null +++ b/docs/design/agent-workflows/scratch/debug-session-turns-append-500.md @@ -0,0 +1,66 @@ +# Debug report: session-turns append HTTP 500 + +Date: 2026-07-19. Stack: the local EE dev deployment (`agenta-ee-dev-wp-b2-rendering-*` containers). All code referenced here is the in-flight sessions redesign, which lives on JP's open PRs #5375 (backend) and #5376 (runner); none of it is on main yet. + +Two terms used throughout. A "turn" is one user-message-to-agent-reply exchange inside a chat session. The "turn-append" is the runner's durable write at the end of each completed turn: it INSERTs one row into the `session_turns` table so that a later runner restart can find the harness's native session id and resume the conversation natively instead of replaying the transcript as text. + +## 1. The verified request path and where the 500 is produced + +The append request goes exactly where it is supposed to go. The lead suggesting the request might be eaten by a redirect or land on the wrong service turned out to be wrong. + +- The runner container runs with `AGENTA_API_INTERNAL_URL=http://api:8000`, which resolves to the EE API container on the same compose network. +- `appendSessionTurn` POSTs to `${apiBase}/sessions/turns/` (services/runner/src/engines/sandbox_agent/session-continuity-durable.ts:168). +- The EE API access log records the failing requests at exactly the runner's timestamps. For the reported session `db58551b-f986-44ec-b939-d6b10b35717a`: + - Runner: `append OK ... turn=0` at 09:52:37.645, then `append HTTP 500 ... turn=0` at 09:53:00.765, 09:54:23.105, 09:54:50.008 (all UTC, 2026-07-19). + - EE API access log: `POST /api/sessions/turns/ HTTP/1.1" 500` at 09:53:00.765, 09:54:23.105, 09:54:50.008. + +The API error log at 09:53:00.764 shows the actual exception: + +``` +asyncpg.exceptions.UniqueViolationError: duplicate key value violates unique +constraint "ix_session_turns_project_id_session_id_turn_index" +DETAIL: Key (project_id, session_id, turn_index) = +(019f4d22-..., db58551b-f986-44ec-b939-d6b10b35717a, 0) already exists. +``` + +The traceback runs through `append_turn` (api/oss/src/apis/fastapi/sessions/router.py:1086) into `SessionTurnsService.append_turn` (api/oss/src/core/sessions/turns/service.py:37) into the DAO's bare `session.add` plus `commit` (api/oss/src/dbs/postgres/sessions/turns/dao.py, `append`). Nothing on that path handles `IntegrityError`, so the `@intercept_exceptions()` decorator (api/oss/src/utils/exceptions.py:119) converts it into a generic 500. The database confirms the state: `session_turns` holds exactly one row for this session, `(db58551b-..., turn_index 0, pi_core, created 09:52:37)`, matching the one append that succeeded. + +## 2. The root cause + +The runner computes the turn index once per sandbox environment, not once per turn, so a warm environment that serves several turns keeps re-INSERTing the same index. + +The mechanism, step by step: + +- `acquireEnvironment` sets `environment.continuityTurnIndex = nextTurnIndex(...)` exactly once, at environment acquire time (services/runner/src/engines/sandbox_agent/environment.ts:962). For a fresh session that value is 0. +- The runner keeps finished environments warm in a keep-alive pool. When the next user message arrives within the idle TTL, the server takes the pooled environment and calls `engine.runTurn(live.environment, ...)` directly (the `hit-continue` branch, services/runner/src/server.ts:610). `acquireEnvironment` never runs again, so `continuityTurnIndex` stays frozen at its acquire-time value. +- At the end of every completed turn, `runTurn` records that same frozen index into the in-memory store and fires the durable append with it (services/runner/src/engines/sandbox_agent/run-turn.ts:572 and :584). + +The first completed turn INSERTs `turn_index=0` and succeeds. Every later turn served by the same warm environment INSERTs `turn_index=0` again, which violates the unique index `(project_id, session_id, turn_index)` that migration oss000000014 created, and the API returns 500. That is precisely the db58551b log shape: one `append OK turn=0`, then `append HTTP 500 turn=0` on each following warm turn. + +The second log shape confirms the same mechanism from the other direction. Session `e744a157-...` on 2026-07-18 shows indexes incrementing (turn=0 through turn=10) because its turns arrived five or more minutes apart, past the 60-second idle TTL, so each turn triggered a fresh cold acquire that recomputed the index. Its 500s appear exactly when a turn DID reuse a warm environment (duplicate turn=10, duplicate turn=11), and its late `turn=2` and `turn=3` failures came from a second pooled environment (the pool logged `poolSize=2`; approval-parked environments live for 300 seconds) that still carried the frozen index from its own, much earlier acquire. + +A secondary defect sits on the API side: the write path treats a uniqueness conflict as an unknown error. The interceptor already maps `EntityCreationConflict` (api/oss/src/core/shared/exceptions.py) to a 409 Conflict response, but the turns DAO never raises it, so a duplicate INSERT surfaces as a 500 with a full traceback in the error log. + +## 3. Blast radius + +The failed append is fire-and-forget (`void ... .catch(() => {})` in run-turn.ts:584), so no turn fails and users see nothing at the moment of the error. The damage is deferred and silent: + +- Most turns never reach the durable turn log. For a busy session only the first completed turn per environment acquire gets a row. After a runner restart, `hydrateHarnessSessionFromDurable` and the sandbox reconnect ladder (`fetchLatestSessionTurn` in sandbox-reconnect.ts:35) read a stale latest row. In the common single-environment case the row still happens to carry the right `agent_session_id` and `sandbox_id`, because those do not change across warm turns. But whenever more than one environment served the session (the e744a157 case), the latest surviving row can point at a dead sandbox and an outdated native session, so cold starts lose native continuity and degrade to transcript replay. That is exactly the degradation this table was built to prevent. +- Per-turn metadata is lost. Each row was meant to carry that turn's `stream_id` and `trace_id`, linking the turn to its trace. For every turn whose append 500s, that linkage row simply does not exist, which undermines anything that joins turns to traces (the records/turn-span work in the same PR stack). +- `turn_index` is not a turn counter. Even the rows that do land carry indexes that count environment acquires, not conversation turns, so any consumer treating the index as "how many turns has this conversation had" gets wrong numbers. +- Operational noise: every duplicate produces a full traceback in the API error log and a misleading 500 in the access log, which is how this investigation started with a wrong lead. + +## 4. The minimal fix and where it should land + +The unique index is correct and should stay; it is the thing that caught the bug. The runner's turn-index accounting is the component to change, with a small API-side hardening alongside it. + +- Primary fix, on PR #5376's lane (the runner side of the sessions redesign): compute the turn index per turn instead of per acquire. Move the `nextTurnIndex` call from `acquireEnvironment` (environment.ts:962) to the start of `runTurn`, reading the process-wide `SessionContinuityStore` each time. The store already advances on every successful `record()` (session-continuity.ts:47), so a per-turn read yields 0, 1, 2, ... naturally, and it also fixes the two-pooled-environments case because both environments read the same shared store. `continuityTurnIndex` can stay as a field on the environment for the record-after-turn symmetry; it just needs to be refreshed at turn start. +- Secondary fix, on PR #5375's lane (the backend side): in `SessionTurnsDAO.append`, catch the SQLAlchemy `IntegrityError` for this unique constraint and raise `EntityCreationConflict`, which the existing interceptor already converts to a 409. A duplicate append then reads as what it is (a conflict on an idempotent-ish write) instead of an anonymous server error, and the runner's log line becomes diagnosable on sight. + +Both fixes belong on the existing PR lanes, not a new one: the bug lives entirely inside code those two open PRs introduce, and landing the fix anywhere else would leave the PRs shipping a known-broken write path. + +## 5. Open questions + +- Should the runner retry a 409 with a freshly computed index, or is dropping the row acceptable? Within one Node process the single-threaded event loop makes read-then-record effectively atomic, but two runner replicas serving the same session could still collide. Today the local provider forbids multi-replica sessions (`LocalSandboxNotOwnerError`), so a retry may be over-engineering for now. +- Is `turn_index` meant to be a true conversation turn counter for consumers beyond continuity (analytics, the UI, the turn-span join)? If yes, the per-turn fix gives correct values going forward, but rows written while the bug was live carry wrong indexes; the dev database may deserve a wipe of `session_turns` before review. +- The e744a157 anomaly showed an approval-parked environment appending an index from five turns earlier. After the per-turn fix its append would compute the current latest index instead; worth a unit test covering "two pooled environments, interleaved completed turns" in services/runner/tests/unit/session-continuity-durable.test.ts. diff --git a/docs/design/agent-workflows/scratch/zed-acp-approvals-comparison.md b/docs/design/agent-workflows/scratch/zed-acp-approvals-comparison.md new file mode 100644 index 0000000000..41562f328f --- /dev/null +++ b/docs/design/agent-workflows/scratch/zed-acp-approvals-comparison.md @@ -0,0 +1,134 @@ +# Comparison of human approval handling in Zed and Agenta + +## The incident and the design question + +In session `db58551b`, the model proposed two parallel shell commands. Each command required human approval. Both commands ran exactly once, and each ran only after the user approved it. No unapproved command executed. + +The system failed around that execution. It lost an approval after rebuilding the saved conversation, failed to send the final approval to the runner, misreported both commands, and later treated a new user message as the missing answer to the old task. The companion report reconstructs the incident and defines the seven defects used below (`docs/design/agent-workflows/scratch/debug-concurrent-approvals-db58551b.md:48`). + +Zed and Agenta use the same approval adapter and can both keep a live approval request waiting inside an agent process. The important difference appears after Agenta closes the browser response. Agenta needs another browser request to deliver the answer, but its durable record does not contain that answer. Its pause cleanup also assigns final results without reliable evidence from the executor. + +The Zed citations below use paths relative to the cloned `agent-client-protocol`, `claude-code-acp`, and `zed` repositories. The Agenta citations use paths relative to this repository. + +## ACP assigns execution to the agent and approval to the client + +ACP, or the Agent Client Protocol, defines how a coding agent and a user interface communicate. For example, ACP lets Claude Code ask Zed whether it may run `git status`. + +ACP uses JSON-RPC, which is a request and response format based on JSON. The sender gives each request an `id`. The receiver eventually returns a response with the same `id`. Several requests can remain unanswered at the same time because their ids keep them separate. + +ACP defines two roles: + +- The **agent** runs the model and executes its tools. In the example above, Claude Code is the agent. +- The **client** displays the conversation and answers the agent's questions. In the example above, Zed is the client. + +A **tool call** is the model's request to perform an action, such as reading a file or running a command. A **permission request** is the agent's request for the human to allow or reject one tool call. The client answers the permission request, but the agent executes the tool. ACP states this division directly (`agent-client-protocol/docs/protocol/v2/tool-calls.mdx:255`). + +A **tool-call status** records the call's current stage, such as pending, waiting for confirmation, running, completed, failed, rejected, or canceled. A **turn** contains one user message and the agent work that follows it. A **session** contains the agent's conversation state across turns. + +ACP also defines `requires_action`, a session state that means the agent cannot continue until the user acts. The agent should report `requires_action` while awaiting permission and `running` after receiving the answer (`agent-client-protocol/docs/protocol/v2/prompt-lifecycle.mdx:355`). + +In Agenta, a **harness** is the agent program that implements the model and tool loop, such as Pi or Claude Code. The runner acts as the ACP client toward that harness. It also streams events to the browser and persists an event stream, which is a chronological record that lets a reloaded page or a second browser rebuild the conversation. + +## Zed and Agenta use the same Claude adapter + +Zed's `claude-code-acp` repository publishes the same npm package that our runner uses: `@agentclientprotocol/claude-agent-acp`. Zed's repository contains version `0.59.0`, while our runner pins version `0.58.1` (`claude-code-acp/package.json:2`, `claude-code-acp/package.json:6`, `services/runner/package.json:23`). + +The adapter does not serialize approvals, which would mean forcing every approval to wait behind the previous one. Its `canUseTool` callback, the function that decides whether a tool may run, awaits `client.requestPermission` without an approval lock or queue (`claude-code-acp/src/acp-agent.ts:4112`, `claude-code-acp/src/acp-agent.ts:4334`). Its only queue, `turnQueue`, orders whole user turns (`claude-code-acp/src/acp-agent.ts:301`, `claude-code-acp/src/acp-agent.ts:1634`). + +ACP permits any number of outstanding permission requests, meaning requests that were sent but not yet answered. Each request has its own JSON-RPC id. The specification demonstrates two concurrent requests and later cancels each id separately (`agent-client-protocol/docs/protocol/v2/cancellation.mdx:52`). + +If Claude presents approvals one at a time, Claude's own tool dispatch creates that ordering. Neither ACP nor the shared adapter requires it. + +## Zed stores each approval on its tool call + +Zed processes one approval as follows. + +1. The adapter first emits the `tool_call` update. It does this deliberately so the client knows which tool call exists before receiving a permission request that refers to it (`claude-code-acp/src/acp-agent.ts:4112`, `claude-code-acp/src/acp-agent.ts:4153`). + +2. Zed creates a **one-shot channel**, which is a channel that carries one value and then closes. In this case, Zed stores the sending end on the tool call and waits on the receiving end until the human chooses allow or reject. The stored sender acts as that call's resolver, meaning it supplies the answer to the waiting request. + +3. Zed changes that call's status to `WaitingForConfirmation`. The status contains the approval options and its own one-shot resolver (`zed/crates/acp_thread/src/acp_thread.rs:1258`, `zed/crates/acp_thread/src/acp_thread.rs:3372`, `zed/crates/acp_thread/src/acp_thread.rs:3386`). + +4. When the human answers, Zed finds the tool call by id. It changes the status to `InProgress` or `Rejected`, then fires only that call's resolver (`zed/crates/acp_thread/src/acp_thread.rs:3425`, `zed/crates/acp_thread/src/acp_thread.rs:3463`). + +The answer is therefore the status transition itself. A redraw reads `InProgress` or `Rejected`; it cannot show the answered call as waiting. Several calls can wait together because each call has its own status and resolver. + +Zed normally leaves the permission request pending without a time limit. The live agent process and the current user turn remain open until the human answers or cancels (`zed/crates/acp_thread/src/acp_thread.rs:3372`). + +A new user message follows a different path. Zed first cancels the running turn, marks every pending or running tool call as `Canceled`, waits for cancellation to finish, and only then sends the new message to the same session (`zed/crates/acp_thread/src/acp_thread.rs:3724`, `zed/crates/acp_thread/src/acp_thread.rs:3733`, `zed/crates/acp_thread/src/acp_thread.rs:3911`). + +The adapter also gives each permission request an `AbortSignal`, which is a cancellation object tied to that request. Aborting it sends `$/cancel_request` for the corresponding JSON-RPC id (`claude-code-acp/src/acp-agent.ts:4105`). ACP requires the client to answer every pending permission request with `cancelled` and recommends marking all unfinished tool calls as canceled (`agent-client-protocol/docs/protocol/v2/prompt-lifecycle.mdx:495`, `agent-client-protocol/docs/protocol/v2/prompt-lifecycle.mdx:497`). + +## Agenta has three continuation paths + +Agenta does not routinely reconstruct and replay the conversation. It has three paths. + +### Warm park and warm resume + +To **park** an approval means ending the browser-facing turn while keeping the live harness process and its permission request waiting. A **keepalive pool** is the runner's bounded collection of these live processes. Each entry has a time-to-live limit, which is the maximum time it may remain parked before the runner destroys it (`services/runner/src/engines/sandbox_agent/session-pool.ts:222`). + +A **warm resume** continues that same live process. The next browser request checks the process out of the pool and answers the original permission request by its tool-call id. The original prompt then continues in place. The model does not issue the tool call again (`services/runner/src/engines/sandbox_agent/run-turn.ts:433`, `services/runner/src/engines/sandbox_agent/run-turn.ts:471`). + +Pi uses this mechanism for builtin tools. Its in-process confirmation waits indefinitely. It allows execution only when the answer is exactly `true`; an error or cancellation denies execution. This is fail-closed behavior (`services/runner/src/extensions/agenta.ts:94`, `services/runner/src/extensions/agenta.ts:112`). + +Warm park therefore uses the same pending-request mechanism as Zed. Three differences matter: + +1. Agenta bounds the wait with a time-to-live limit and evicts the process when that limit expires. Zed applies no normal approval timeout. +2. Agenta ends the browser-facing turn when it parks. The frontend must send a new resume request to deliver the answer. Zed keeps one turn open, so the answer returns through the still-open request. +3. Agenta's frontend resume trigger failed during the incident. The live permission request remained valid, but its answer never reached it. + +### Cold continue + +**Session continuity** is the ability to preserve the agent's conversation state when the runner must create a new process or environment. On a keepalive pool miss, Agenta creates a fresh environment and asks the harness to load its own session. The runner finds that session through the harness session id stored on the latest durable session turn (`services/runner/src/engines/sandbox_agent/session-continuity-durable.ts:1`, `services/runner/src/engines/sandbox_agent/session-continuity-durable.ts:58`). + +The fresh environment calls the harness's native session load, which means the harness restores its own saved state instead of receiving a reconstructed conversation from Agenta. If loading succeeds, the runner sends only the new user text (`services/runner/src/engines/sandbox_agent/environment.ts:929`, `services/runner/src/engines/sandbox_agent/environment.ts:982`, `services/runner/src/engines/sandbox_agent/runtime-contracts.ts:145`). The model does not reissue the earlier tool calls. + +### Failure fallback + +Only when the continuity lookup or native session load is unavailable does Agenta use replay as a failure fallback. **Replay** means rebuilding the prompt from Agenta's persisted event history and sending that reconstructed history to a new harness session (`services/runner/src/engines/sandbox_agent/run-turn.ts:124`). + +## Agenta persists the request but not the answer + +When the runner asks the human for approval, it writes an `interaction_request` event into the durable session record (`services/runner/src/engines/sandbox_agent/acp-interactions.ts:178`). It also creates a pending interaction row. + +When the human answers, the browser stores the choice in its local message state and sends it with the resume request (`web/oss/src/components/AgentChatSlice/AgentConversation.tsx:1032`). The runner later changes the interaction row to `resolved`, but that update contains only the lifecycle status. It does not store the allow or deny verdict (`services/runner/src/sessions/interactions.ts:94`, `services/runner/src/sessions/interactions.ts:104`). The protocol defines no `interaction_response` event (`services/runner/src/protocol.ts:363`). + +The answer therefore exists only in browser memory and inside the in-flight resume request. + +This produces two direct consequences. First, a page reload, a second browser, or a frontend re-render that rebuilds state from the database sees every `interaction_request` as `approval-requested` (`web/oss/src/components/AgentChatSlice/assets/transcriptToMessages.ts:144`, `web/oss/src/components/AgentChatSlice/assets/transcriptToMessages.ts:172`). Every answered gate appears to be waiting again. + +Second, the frontend sends the resume only when every visible approval card looks settled (`web/packages/agenta-playground/src/state/execution/agentApprovalResume.ts:163`). After a rebuild, at least one answered card looks pending. The condition can never become true. This is why the final approval remained in browser memory and the conversation stopped. + +## The seven defects differ from Zed at specific boundaries + +The hardest execution problem occurs when one call is awaiting approval, another is already executing, and a third has not started. A cleanup pass can see that all three calls remain open, but it cannot infer which actions occurred. That missing evidence caused defects 2 and 3. + +| Defect | Comparison with Zed | +|---|---| +| **1. The frontend never dispatches the final answer.** | Agenta waits for every card to look settled before sending a resume. Zed answers one tool-call id immediately and does not depend on sibling cards. | +| **2. The runner reports an approved, executing call as not executed.** | Agenta's post-pause sweep writes a synthetic `DEFERRED_NOT_EXECUTED` error onto open calls, including a call that is already running, then drops its later real result (`services/runner/src/engines/sandbox_agent/run-turn.ts:507`, `services/runner/src/tracing/otel.ts:1479`). Zed advances status only from the selected answer and agent updates. | +| **3. The runner reports a never-started call as a successful empty result.** | Agenta accepts a `completed` closure frame and maps it to success even when execution never began (`services/runner/src/engines/sandbox_agent/runtime-policy.ts:48`, `services/runner/src/tracing/otel.ts:1445`). Zed records unfinished calls as pending or canceled unless the agent reports a real result. | +| **4. The answer is not persisted.** | Agenta saves the permission request but not the verdict. Zed makes the answer a status transition on the tool call. | +| **5. The persisted record overwrites history.** | Agenta derives result-row ids from the session, tool-call id, and event type, without the turn id, so a later result can replace an earlier result (`services/runner/src/sessions/persist.ts:295`). Approval resumes also persist duplicate prompts. Per-call approval status fixes the rebuild problem, but full audit history still requires turn-scoped, append-only rows. | +| **6. A new message resumes the stale task.** | The incident's new message carried the stored approval, so Agenta consumed the request as a resume and completed the old task. Zed cancels the old turn before sending new text. | +| **7. Session-turn append returns HTTP 500.** | This failure affects durable session continuity but is unrelated to approval concurrency. It needs a separate investigation (`docs/design/agent-workflows/scratch/debug-concurrent-approvals-db58551b.md:158`). | + +No unapproved command ran in the incident. Pi's builtin confirmation and execution live in the same process, and the confirmation fails closed. This matches Zed's `canUseTool` shape. + +The **client-tool relay** is the separate path where the harness asks the runner to execute a product tool on its behalf. Only on that path do the approval gate and executor live in different processes. Runner-side cancellation remains necessary there as a safety backstop. + +## Recommended changes + +1. Persist each approval answer as a durable status transition on its tool call. Store the allow or deny verdict, actor, and time. A rebuilt conversation must read `approved`, `denied`, or a later execution status instead of reconstructing `waiting` from the request alone. This fixes defects 1 and 4 and the rebuild part of defect 5. + +2. Dispatch each approval independently. One click should persist and deliver one answer by tool-call id. The frontend must not wait for every visible card to settle, and one stale card must not block another answer. + +3. Cancel before accepting new user work. When new text arrives during pending approvals, the runner should send `session/cancel`, mark unfinished calls canceled, answer every pending permission request with `cancelled`, wait for the harness to report an idle canceled state, and then send the new text as a fresh prompt to the same session. This fixes defect 6 and implements ACP's cancellation contract. + +4. Accept only real terminal evidence. Only the executor may mark a call completed or failed. An explicit cancellation may mark it canceled. A pause sweep must not invent a success or a failure for an open call. This removes the false results behind defects 2 and 3 and prevents the retry instruction from repeating a command that already ran. + +5. Keep Agenta's durable event stream. Several browsers and page reloads are valid product requirements. The fix is to persist every state transition and scope result records by turn, not to replace durable history with Zed's in-memory state. + +6. Investigate defect 7 separately. A failed `session_turns` append weakens cold session continuity, but it did not cause the approval incident. + +Updating the shared Claude adapter alone will not fix these defects. The adapter already supports concurrent permission requests and per-request cancellation. The required changes belong in Agenta's browser dispatch, runner state transitions, cancellation path, and persistence model. diff --git a/docs/docs/self-host/reference/01-configuration.mdx b/docs/docs/self-host/reference/01-configuration.mdx index 59842a060d..6a16e03f50 100644 --- a/docs/docs/self-host/reference/01-configuration.mdx +++ b/docs/docs/self-host/reference/01-configuration.mdx @@ -218,12 +218,14 @@ run multiple runners, use the Daytona provider, whose sessions any runner can re ### Routing and authentication These locate the runner and authenticate the call. Two of the three are caller-side: the Services -API and its SDK read them, not the runner. +API and its SDK read them, not the runner. The platform API also reads the locator and token +directly, for the session `kill` verb's sandbox teardown (`POST /kill` on the runner) — a second, +independent caller of the same runner HTTP surface, not routed through Services. | Variable | Role | Read by | Default | Secret | Helm | |---|---|---|---|---|---| -| `AGENTA_RUNNER_INTERNAL_URL` | Runner locator | Services API (caller-side) | Compose: `http://runner:8765` | no | `agentRunner.externalUrl`, else derived from `agentRunner.enabled` | -| `AGENTA_RUNNER_TOKEN` | Shared request credential | Services sends it; runner verifies it | **Required — no default** | yes | `agenta.runnerToken`, or `agentRunner.auth.tokenSecretRef` to source it from your own Secret | +| `AGENTA_RUNNER_INTERNAL_URL` | Runner locator | Services API and the platform API (both caller-side) | Compose: `http://runner:8765` | no | `agentRunner.externalUrl`, else derived from `agentRunner.enabled` | +| `AGENTA_RUNNER_TOKEN` | Shared request credential | Services and the platform API send it; runner verifies it | **Required — no default** | yes | `agenta.runnerToken`, or `agentRunner.auth.tokenSecretRef` to source it from your own Secret | | `AGENTA_RUNNER_TIMEOUT_SECONDS` | Idle timeout the caller allows on the run request | Services API and SDK (caller-side) | `360` | no | n/a | `AGENTA_RUNNER_TOKEN` is **required**, like `AGENTA_AUTH_KEY`. The runner refuses to start without @@ -242,11 +244,11 @@ Kubernetes, set `agenta.runnerToken` (the chart fails to render without it), or The token is defense in depth on top of network isolation, not a replacement for it: keep the runner on a private network and never publish its port. -`AGENTA_RUNNER_TOKEN` is the same credential at both ends. It does not belong to a sandbox or a -harness. The runner refuses to start without it and rejects any un-tokened request with `401`; there -is no unauthenticated mode. Set the same value on the runner and on the Services API; in Helm, point -`agentRunner.auth.tokenSecretRef` at a Secret holding it — the chart injects the same value into both -the runner and the Services deployment. +`AGENTA_RUNNER_TOKEN` is the same credential across all parties. It does not belong to a sandbox or +a harness. The runner refuses to start without it and rejects any un-tokened request with `401`; +there is no unauthenticated mode. Set the same value on the runner, the Services API, and the +platform API; in Helm, point `agentRunner.auth.tokenSecretRef` at a Secret holding it — the chart +injects the same value into the runner, Services, and API deployments. `AGENTA_RUNNER_TIMEOUT_SECONDS` bounds idle time on the caller's streaming transport, not the total run duration, and the runner never reads it. The runner enforces its own deadlines through diff --git a/hosting/docker-compose/ee/docker-compose.dev.yml b/hosting/docker-compose/ee/docker-compose.dev.yml index 19956cb1c8..b26c27e0cf 100644 --- a/hosting/docker-compose/ee/docker-compose.dev.yml +++ b/hosting/docker-compose/ee/docker-compose.dev.yml @@ -125,6 +125,10 @@ services: - ${ENV_FILE:-./.env.ee.dev} environment: DOCKER_NETWORK_MODE: ${DOCKER_NETWORK_MODE:-bridge} + # kill's direct runner hop (W7.3, sandbox teardown) — same compose-network + # address + shared token as the `services` container's runner_url(). + AGENTA_RUNNER_INTERNAL_URL: ${AGENTA_RUNNER_INTERNAL_URL:-http://runner:8765} + AGENTA_RUNNER_TOKEN: ${AGENTA_RUNNER_TOKEN:?AGENTA_RUNNER_TOKEN is required} # === NETWORK ============================================== # networks: - agenta-network diff --git a/hosting/docker-compose/ee/docker-compose.gh.yml b/hosting/docker-compose/ee/docker-compose.gh.yml index 28e2046a14..2e71f84034 100644 --- a/hosting/docker-compose/ee/docker-compose.gh.yml +++ b/hosting/docker-compose/ee/docker-compose.gh.yml @@ -54,6 +54,10 @@ services: environment: - SCRIPT_NAME=/api - DOCKER_NETWORK_MODE=${DOCKER_NETWORK_MODE:-bridge} + # kill's direct runner hop (W7.3, sandbox teardown) — same address + shared + # token as the `services` container's runner_url(). + - AGENTA_RUNNER_INTERNAL_URL=${AGENTA_RUNNER_INTERNAL_URL:-http://runner:8765} + - AGENTA_RUNNER_TOKEN=${AGENTA_RUNNER_TOKEN:?AGENTA_RUNNER_TOKEN is required} - AGENTA_STORE_ENDPOINT_URL=${AGENTA_STORE_ENDPOINT_URL:-} - AGENTA_STORE_ACCESS_KEY=${AGENTA_STORE_ACCESS_KEY:-} - AGENTA_STORE_SECRET_KEY=${AGENTA_STORE_SECRET_KEY:-} diff --git a/hosting/docker-compose/oss/docker-compose.dev.yml b/hosting/docker-compose/oss/docker-compose.dev.yml index a2fb80bb18..604d62bfa1 100644 --- a/hosting/docker-compose/oss/docker-compose.dev.yml +++ b/hosting/docker-compose/oss/docker-compose.dev.yml @@ -120,6 +120,10 @@ services: - ${ENV_FILE:-./.env.oss.dev} environment: DOCKER_NETWORK_MODE: ${DOCKER_NETWORK_MODE:-bridge} + # kill's direct runner hop (W7.3, sandbox teardown) — same compose-network + # address + shared token as the `services` container's runner_url(). + AGENTA_RUNNER_INTERNAL_URL: ${AGENTA_RUNNER_INTERNAL_URL:-http://runner:8765} + AGENTA_RUNNER_TOKEN: ${AGENTA_RUNNER_TOKEN:?AGENTA_RUNNER_TOKEN is required} # === NETWORK ============================================== # networks: - agenta-network diff --git a/hosting/docker-compose/oss/docker-compose.gh.yml b/hosting/docker-compose/oss/docker-compose.gh.yml index d8fa3330b5..a9777adfa9 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.yml @@ -54,6 +54,10 @@ services: environment: - SCRIPT_NAME=/api - DOCKER_NETWORK_MODE=${DOCKER_NETWORK_MODE:-bridge} + # kill's direct runner hop (W7.3, sandbox teardown) — same address + shared + # token as the `services` container's runner_url(). + - AGENTA_RUNNER_INTERNAL_URL=${AGENTA_RUNNER_INTERNAL_URL:-http://runner:8765} + - AGENTA_RUNNER_TOKEN=${AGENTA_RUNNER_TOKEN:?AGENTA_RUNNER_TOKEN is required} - AGENTA_STORE_ENDPOINT_URL=${AGENTA_STORE_ENDPOINT_URL:-} - AGENTA_STORE_ACCESS_KEY=${AGENTA_STORE_ACCESS_KEY:-} - AGENTA_STORE_SECRET_KEY=${AGENTA_STORE_SECRET_KEY:-} diff --git a/hosting/kubernetes/helm/templates/api-deployment.yaml b/hosting/kubernetes/helm/templates/api-deployment.yaml index 8b9e1c6acf..8143adc034 100644 --- a/hosting/kubernetes/helm/templates/api-deployment.yaml +++ b/hosting/kubernetes/helm/templates/api-deployment.yaml @@ -75,6 +75,7 @@ spec: {{- include "agenta.commonEnv" . | nindent 12 }} - name: SCRIPT_NAME value: "/api" + {{- include "agenta.agentRunner.servicesEnv" . | nindent 12 }} {{- range $key, $val := $api.env }} - name: {{ $key }} value: {{ $val | quote }} diff --git a/sdks/python/agenta/sdk/agents/__init__.py b/sdks/python/agenta/sdk/agents/__init__.py index 9ab8ef6e2f..f25778e075 100644 --- a/sdks/python/agenta/sdk/agents/__init__.py +++ b/sdks/python/agenta/sdk/agents/__init__.py @@ -65,7 +65,7 @@ HarnessAgentTemplate, HarnessCapabilities, HarnessIdentity, - HarnessType, + HarnessKind, InvalidPermissionDefaultError, Message, NetworkEgress, @@ -160,7 +160,7 @@ "PiAgentTemplate", "ClaudeAgentTemplate", "AgentaAgentTemplate", - "HarnessType", + "HarnessKind", "HarnessIdentity", "HARNESS_IDENTITIES", "HarnessCapabilities", diff --git a/sdks/python/agenta/sdk/agents/adapters/harnesses.py b/sdks/python/agenta/sdk/agents/adapters/harnesses.py index e04c60be75..8fcf837086 100644 --- a/sdks/python/agenta/sdk/agents/adapters/harnesses.py +++ b/sdks/python/agenta/sdk/agents/adapters/harnesses.py @@ -26,7 +26,7 @@ from ..dtos import ( AgentaAgentTemplate, ClaudeAgentTemplate, - HarnessType, + HarnessKind, PiAgentTemplate, SessionConfig, ) @@ -56,7 +56,7 @@ def _normalize_tool_specs(specs: List[Dict[str, Any]]) -> List[ToolSpec]: class PiHarness(Harness): - harness_type = HarnessType.PI + harness_type = HarnessKind.PI def _to_harness_config(self, config: SessionConfig) -> PiAgentTemplate: # Pi delivers tools natively: built-in names plus resolved specs registered through @@ -86,7 +86,7 @@ def _to_harness_config(self, config: SessionConfig) -> PiAgentTemplate: class ClaudeHarness(Harness): - harness_type = HarnessType.CLAUDE + harness_type = HarnessKind.CLAUDE def _to_harness_config(self, config: SessionConfig) -> ClaudeAgentTemplate: # Claude has no Pi built-in tools; drop them rather than ship a name Claude cannot @@ -125,7 +125,7 @@ class AgentaHarness(Harness): forced platform skill(s) are unioned in (de-duped by name) so a custom config that drops the default template's ``_agenta`` embed still carries the platform skill.""" - harness_type = HarnessType.AGENTA + harness_type = HarnessKind.AGENTA def _to_harness_config(self, config: SessionConfig) -> AgentaAgentTemplate: # The author's Pi options still apply; the pi_agenta harness reads the same harness @@ -153,15 +153,15 @@ def _to_harness_config(self, config: SessionConfig) -> AgentaAgentTemplate: ) -_HARNESSES: Dict[HarnessType, Type[Harness]] = { - HarnessType.PI: PiHarness, - HarnessType.CLAUDE: ClaudeHarness, - HarnessType.AGENTA: AgentaHarness, +_HARNESSES: Dict[HarnessKind, Type[Harness]] = { + HarnessKind.PI: PiHarness, + HarnessKind.CLAUDE: ClaudeHarness, + HarnessKind.AGENTA: AgentaHarness, } def make_harness( - harness_type: "HarnessType | str", environment: Environment + harness_type: "HarnessKind | str", environment: Environment ) -> Harness: """Construct the Harness for a harness type over an environment. @@ -169,7 +169,7 @@ def make_harness( :class:`~agenta.sdk.agents.errors.UnsupportedHarnessError` if the environment's backend cannot drive it. """ - resolved = HarnessType.coerce(harness_type) + resolved = HarnessKind.coerce(harness_type) try: cls = _HARNESSES[resolved] except KeyError as exc: diff --git a/sdks/python/agenta/sdk/agents/adapters/local.py b/sdks/python/agenta/sdk/agents/adapters/local.py index 68d0332f34..4f2dfe761d 100644 --- a/sdks/python/agenta/sdk/agents/adapters/local.py +++ b/sdks/python/agenta/sdk/agents/adapters/local.py @@ -22,14 +22,14 @@ from typing import Mapping, Optional -from ..dtos import HarnessAgentTemplate, HarnessType, RunContext, TraceContext +from ..dtos import HarnessAgentTemplate, HarnessKind, RunContext, TraceContext from ..interfaces import Backend, Sandbox, Session class LocalBackend(Backend): """Run Pi (bundled JS) or Claude (``claude-agent-sdk``) on this machine.""" - supported_harnesses = frozenset({HarnessType.PI, HarnessType.CLAUDE}) + supported_harnesses = frozenset({HarnessKind.PI, HarnessKind.CLAUDE}) async def create_sandbox(self) -> Sandbox: raise NotImplementedError( @@ -42,7 +42,7 @@ async def create_session( sandbox: Sandbox, config: HarnessAgentTemplate, *, - harness: HarnessType, + harness: HarnessKind, secrets: Optional[Mapping[str, str]] = None, trace: Optional[TraceContext] = None, run_context: Optional[RunContext] = None, diff --git a/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py b/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py index f641a994e1..93c7844f95 100644 --- a/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py +++ b/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py @@ -21,7 +21,7 @@ AgentResult, EventSink, HarnessAgentTemplate, - HarnessType, + HarnessKind, Message, RunContext, TraceContext, @@ -63,7 +63,7 @@ def __init__( sandbox: SandboxAgentSandbox, config: HarnessAgentTemplate, *, - harness: HarnessType, + harness: HarnessKind, secrets: Optional[Mapping[str, str]], trace: Optional[TraceContext], run_context: Optional[RunContext], @@ -124,7 +124,7 @@ class SandboxAgentBackend(Backend): """The sandbox-agent engine: a harness over ACP through the TS runner. Pi, Claude, and Agenta.""" supported_harnesses = frozenset( - {HarnessType.PI, HarnessType.CLAUDE, HarnessType.AGENTA} + {HarnessKind.PI, HarnessKind.CLAUDE, HarnessKind.AGENTA} ) def __init__( @@ -155,7 +155,7 @@ async def create_session( sandbox: Sandbox, config: HarnessAgentTemplate, *, - harness: HarnessType, + harness: HarnessKind, secrets: Optional[Mapping[str, str]] = None, trace: Optional[TraceContext] = None, run_context: Optional[RunContext] = None, diff --git a/sdks/python/agenta/sdk/agents/adapters/vercel/messages.py b/sdks/python/agenta/sdk/agents/adapters/vercel/messages.py index d66c4f5cfe..86c3668c7c 100644 --- a/sdks/python/agenta/sdk/agents/adapters/vercel/messages.py +++ b/sdks/python/agenta/sdk/agents/adapters/vercel/messages.py @@ -196,12 +196,19 @@ def _tool_part_blocks(part: Dict[str, Any], ptype: str) -> List[ContentBlock]: if isinstance(_input, dict) else type(_input).__name__, ) + approval_output: Dict[str, Any] = {"approved": approved} + approval = part.get("approval") + interaction_token = ( + approval.get("id") if isinstance(approval, dict) else None + ) + if isinstance(interaction_token, str) and interaction_token: + approval_output["interactionToken"] = interaction_token blocks.append( ContentBlock( type="tool_result", tool_call_id=tool_call_id, tool_name=tool_name, - output={"approved": approved}, + output=approval_output, ) ) return blocks @@ -229,7 +236,13 @@ def _approval_response_blocks(part: Dict[str, Any]) -> List[ContentBlock]: output = part.get("output") if output is None: approved = part.get("approved") - output = {"approved": approved} if approved is not None else part.get("reason") + if approved is not None: + output = {"approved": approved} + interaction_token = part.get("approvalId") + if isinstance(interaction_token, str) and interaction_token: + output["interactionToken"] = interaction_token + else: + output = part.get("reason") return [ContentBlock(type="tool_result", tool_call_id=tool_call_id, output=output)] diff --git a/sdks/python/agenta/sdk/agents/dtos.py b/sdks/python/agenta/sdk/agents/dtos.py index 455e120f99..b7b733d2c3 100644 --- a/sdks/python/agenta/sdk/agents/dtos.py +++ b/sdks/python/agenta/sdk/agents/dtos.py @@ -42,7 +42,7 @@ # --------------------------------------------------------------------------- -class HarnessType(str, Enum): +class HarnessKind(str, Enum): """The coding agent program a run drives. A backend declares which it supports. ``pi_core`` is plain Pi; ``pi_agenta`` is Pi with Agenta's forced skills, prompt, and @@ -54,7 +54,7 @@ class HarnessType(str, Enum): AGENTA = "pi_agenta" @classmethod - def coerce(cls, value: "HarnessType | str") -> "HarnessType": + def coerce(cls, value: "HarnessKind | str") -> "HarnessKind": """Accept either an enum or a loose string (the playground sends a string).""" if isinstance(value, cls): return value @@ -76,7 +76,7 @@ def coerce(cls, value: "HarnessType | str") -> "HarnessType": class HarnessIdentity(BaseModel): """One harness's interface identity: its bare value, versioned slug, and display name. - ``value`` is the wire/runtime selector (the ``HarnessType`` value); ``slug`` is the + ``value`` is the wire/runtime selector (the ``HarnessKind`` value); ``slug`` is the versioned contract identity in the repo's slug grammar; ``name`` is the human-facing label the playground dropdown shows. This is the single source the agent_template schema builds the harness ``oneOf`` from, so the slug, name, and value never drift across the SDK, the service @@ -87,22 +87,22 @@ class HarnessIdentity(BaseModel): name: str -# One entry per ``HarnessType``. The slug version is ``v0`` for every harness today (the +# One entry per ``HarnessKind``. The slug version is ``v0`` for every harness today (the # contract has not broken). ``HARNESS_IDENTITIES`` is the single source of truth. HARNESS_IDENTITIES: List[HarnessIdentity] = [ HarnessIdentity( - value=HarnessType.PI.value, - slug=f"agenta:harness:{HarnessType.PI.value}:v0", + value=HarnessKind.PI.value, + slug=f"agenta:harness:{HarnessKind.PI.value}:v0", name="Pi", ), HarnessIdentity( - value=HarnessType.AGENTA.value, - slug=f"agenta:harness:{HarnessType.AGENTA.value}:v0", + value=HarnessKind.AGENTA.value, + slug=f"agenta:harness:{HarnessKind.AGENTA.value}:v0", name="Pi (Agenta)", ), HarnessIdentity( - value=HarnessType.CLAUDE.value, - slug=f"agenta:harness:{HarnessType.CLAUDE.value}:v0", + value=HarnessKind.CLAUDE.value, + slug=f"agenta:harness:{HarnessKind.CLAUDE.value}:v0", name="Claude Code", ), ] @@ -691,7 +691,7 @@ class HarnessAgentTemplate(BaseModel): model_config = ConfigDict(populate_by_name=True) - harness: ClassVar[HarnessType] + harness: ClassVar[HarnessKind] agents_md: Optional[str] = None # ``model`` stays the back-compat plain string the adapter hands to the harness. @@ -852,7 +852,7 @@ class PiAgentTemplate(HarnessAgentTemplate): either way, so AGENTS.md remains the right home for project conventions and these are only for changing or extending Pi's base persona.""" - harness: ClassVar[HarnessType] = HarnessType.PI + harness: ClassVar[HarnessKind] = HarnessKind.PI builtin_names: List[str] = Field( default_factory=list, @@ -900,7 +900,7 @@ def wire_prompt(self) -> Dict[str, Any]: class ClaudeAgentTemplate(HarnessAgentTemplate): """Claude's config. No Pi built-ins; tools are delivered over MCP.""" - harness: ClassVar[HarnessType] = HarnessType.CLAUDE + harness: ClassVar[HarnessKind] = HarnessKind.CLAUDE tool_specs: List[ToolSpec] = Field( default_factory=list, @@ -958,7 +958,7 @@ class AgentaAgentTemplate(PiAgentTemplate): system-prompt layers). ``skills`` ride the inherited :meth:`wire_skills` seam as resolved inline packages, not through ``wire_tools`` (skills are not tools).""" - harness: ClassVar[HarnessType] = HarnessType.AGENTA + harness: ClassVar[HarnessKind] = HarnessKind.AGENTA # --------------------------------------------------------------------------- diff --git a/sdks/python/agenta/sdk/agents/errors.py b/sdks/python/agenta/sdk/agents/errors.py index d37619b5dd..a1a64b1b1c 100644 --- a/sdks/python/agenta/sdk/agents/errors.py +++ b/sdks/python/agenta/sdk/agents/errors.py @@ -6,7 +6,7 @@ from agenta.sdk.engines.running.errors import ERRORS_BASE_URL, ErrorStatus -from .dtos import HarnessType +from .dtos import HarnessKind from .tools.errors import ToolResolutionError __all__ = [ @@ -23,7 +23,7 @@ class UnsupportedHarnessError(RuntimeError): """Raised when a harness is asked to run on a backend that cannot drive it.""" - def __init__(self, harness: HarnessType, backend: "Backend") -> None: + def __init__(self, harness: HarnessKind, backend: "Backend") -> None: supported = ", ".join(sorted(h.value for h in backend.supported_harnesses)) super().__init__( f"{type(backend).__name__} cannot drive harness '{harness.value}'; " diff --git a/sdks/python/agenta/sdk/agents/interfaces.py b/sdks/python/agenta/sdk/agents/interfaces.py index 28b7ddd36c..dce5efa88a 100644 --- a/sdks/python/agenta/sdk/agents/interfaces.py +++ b/sdks/python/agenta/sdk/agents/interfaces.py @@ -25,7 +25,7 @@ AgentResult, EventSink, HarnessAgentTemplate, - HarnessType, + HarnessKind, Message, RunContext, SessionConfig, @@ -101,9 +101,9 @@ class Backend(ABC): """ #: The single source of truth for what this engine can run. - supported_harnesses: ClassVar[FrozenSet[HarnessType]] = frozenset() + supported_harnesses: ClassVar[FrozenSet[HarnessKind]] = frozenset() - def supports(self, harness: HarnessType) -> bool: + def supports(self, harness: HarnessKind) -> bool: return harness in self.supported_harnesses async def setup(self) -> None: @@ -124,7 +124,7 @@ async def create_session( sandbox: Sandbox, config: HarnessAgentTemplate, *, - harness: HarnessType, + harness: HarnessKind, secrets: Optional[Mapping[str, str]] = None, trace: Optional[TraceContext] = None, run_context: Optional[RunContext] = None, @@ -178,7 +178,7 @@ async def create_session( self, config: HarnessAgentTemplate, *, - harness: HarnessType, + harness: HarnessKind, session_config: SessionConfig, provisioning: Optional[Mapping[str, bytes]] = None, ) -> Session: @@ -211,7 +211,7 @@ class Harness(ABC): the per-harness knowledge lives here. """ - harness_type: ClassVar[HarnessType] + harness_type: ClassVar[HarnessKind] def __init__(self, environment: Environment) -> None: if not environment.backend.supports(self.harness_type): @@ -245,7 +245,7 @@ def _provisioning(self, config: SessionConfig) -> Mapping[str, bytes]: instructions = config.agent.instructions if instructions and instructions.strip(): filename = ( - "CLAUDE.md" if self.harness_type is HarnessType.CLAUDE else "AGENTS.md" + "CLAUDE.md" if self.harness_type is HarnessKind.CLAUDE else "AGENTS.md" ) files[filename] = instructions.encode("utf-8") return files diff --git a/sdks/python/agenta/sdk/agents/utils/wire.py b/sdks/python/agenta/sdk/agents/utils/wire.py index da7c957d46..d8e00e2111 100644 --- a/sdks/python/agenta/sdk/agents/utils/wire.py +++ b/sdks/python/agenta/sdk/agents/utils/wire.py @@ -29,7 +29,7 @@ AgentResult, HarnessAgentTemplate, HarnessCapabilities, - HarnessType, + HarnessKind, Message, RunContext, TraceContext, @@ -81,7 +81,7 @@ def sanitize_runner_error(error: Any) -> str: def request_to_wire( *, - harness: HarnessType, + harness: HarnessKind, sandbox: str, config: HarnessAgentTemplate, messages: Sequence[Message], diff --git a/sdks/python/agenta/sdk/agents/wire_models.py b/sdks/python/agenta/sdk/agents/wire_models.py index eb5366cc1d..759f227a7a 100644 --- a/sdks/python/agenta/sdk/agents/wire_models.py +++ b/sdks/python/agenta/sdk/agents/wire_models.py @@ -378,7 +378,8 @@ class WireEvent(_WireModel): the forward-compatible event types the runner may add, which contradicts the "drop unknown" guarantee. The known ``type`` values are documented for readers, not enforced: ``message``, ``thought``, the ``message_*`` / ``reasoning_*`` lifecycle trios, ``tool_call``, - ``tool_result``, ``interaction_request``, ``data``, ``file``, ``usage``, ``error``, ``done``. + ``tool_result``, ``interaction_request``, ``interaction_response``, ``data``, ``file``, + ``usage``, ``error``, ``done``. """ type: Optional[str] = None diff --git a/sdks/python/agenta/sdk/engines/tracing/tracing.py b/sdks/python/agenta/sdk/engines/tracing/tracing.py index f785528798..4e0a7bb047 100644 --- a/sdks/python/agenta/sdk/engines/tracing/tracing.py +++ b/sdks/python/agenta/sdk/engines/tracing/tracing.py @@ -258,6 +258,26 @@ def store_user( if user_id: span.set_attribute("id", user_id, namespace="user") + def store_agent( + self, + agent_id: Optional[str] = None, + span: Optional[Span] = None, + ): + """Set agent attributes on the current span. + + Args: + agent_id: Unique identifier for the running artifact (workflow / + application / evaluator id) + span: Optional span to set attributes on (defaults to current span) + """ + self._warn_if_not_initialized("store_agent") + with suppress(): + if span is None: + span = self.get_current_span() + + if agent_id: + span.set_attribute("id", agent_id, namespace="agent") + def extract( self, *args, diff --git a/sdks/python/agenta/sdk/middlewares/running/normalizer.py b/sdks/python/agenta/sdk/middlewares/running/normalizer.py index 1d8304ca69..d5f1ad9b64 100644 --- a/sdks/python/agenta/sdk/middlewares/running/normalizer.py +++ b/sdks/python/agenta/sdk/middlewares/running/normalizer.py @@ -117,6 +117,16 @@ async def _normalize_request( return normalized + @staticmethod + def _resolve_agent_id(ctx: RunningContext) -> Optional[str]: + """The running artifact's id (workflow / application / evaluator) — + they all resolve through the same WorkflowRevision.artifact_id.""" + revision = ctx.revision + if not isinstance(revision, dict): + return None + agent_id = revision.get("artifact_id") or revision.get("workflow_id") + return str(agent_id) if agent_id else None + @staticmethod def _correlation_ids(): """trace_id / span_id (from the span link) + session_id — all off the @@ -276,11 +286,16 @@ async def __call__( scoped.session_id = session_id token = TracingContext.set(scoped) try: - if session_id: + agent_id = self._resolve_agent_id(ctx) + + if session_id or agent_id: import agenta as ag if ag.tracing is not None: - ag.tracing.store_session(session_id=session_id) + if session_id: + ag.tracing.store_session(session_id=session_id) + if agent_id: + ag.tracing.store_agent(agent_id=agent_id) kwargs = await self._normalize_request(request, handler) diff --git a/sdks/python/agenta/sdk/models/tracing.py b/sdks/python/agenta/sdk/models/tracing.py index a7c60f3904..68aee3f5f1 100644 --- a/sdks/python/agenta/sdk/models/tracing.py +++ b/sdks/python/agenta/sdk/models/tracing.py @@ -203,6 +203,12 @@ class Span(Lifecycle): status_code: Optional[OTelStatusCode] = None status_message: Optional[str] = None + # Root-span-only (parent_id is None); lifted from ag.session/user/agent + # attributes at ingestion. Never set on child spans. + session_id: Optional[str] = None + user_id: Optional[str] = None + agent_id: Optional[str] = None + attributes: Optional[OTelAttributes] = None references: Optional[OTelReferences] = None links: Optional[OTelLinks] = None diff --git a/sdks/python/oss/tests/pytest/integration/agents/_fake_runner_backend.py b/sdks/python/oss/tests/pytest/integration/agents/_fake_runner_backend.py index 524c3ef93a..5ad3350ef9 100644 --- a/sdks/python/oss/tests/pytest/integration/agents/_fake_runner_backend.py +++ b/sdks/python/oss/tests/pytest/integration/agents/_fake_runner_backend.py @@ -17,7 +17,7 @@ AgentResult, EventSink, HarnessAgentTemplate, - HarnessType, + HarnessKind, Message, RunContext, TraceContext, @@ -53,7 +53,7 @@ def __init__( backend: "FakeRunnerBackend", config: HarnessAgentTemplate, *, - harness: HarnessType, + harness: HarnessKind, secrets: Optional[Mapping[str, str]], trace: Optional[TraceContext], run_context: Optional[RunContext], @@ -120,7 +120,7 @@ class FakeRunnerBackend(Backend): """ supported_harnesses = frozenset( - {HarnessType.PI, HarnessType.CLAUDE, HarnessType.AGENTA} + {HarnessKind.PI, HarnessKind.CLAUDE, HarnessKind.AGENTA} ) def __init__( @@ -149,7 +149,7 @@ async def create_session( sandbox: Sandbox, config: HarnessAgentTemplate, *, - harness: HarnessType, + harness: HarnessKind, secrets: Optional[Mapping[str, str]] = None, trace: Optional[TraceContext] = None, run_context: Optional[RunContext] = None, diff --git a/sdks/python/oss/tests/pytest/integration/agents/_qa_transcripts.py b/sdks/python/oss/tests/pytest/integration/agents/_qa_transcripts.py index 8887c0318d..8867bf5b76 100644 --- a/sdks/python/oss/tests/pytest/integration/agents/_qa_transcripts.py +++ b/sdks/python/oss/tests/pytest/integration/agents/_qa_transcripts.py @@ -35,8 +35,8 @@ ) # The QA captures predate the harness-value rename (``matrix.md``'s 2026-06-25 wire-contract -# note): the wire moved from ``pi``/``agenta``/``claude`` to today's ``HarnessType`` values -# ``pi_core``/``pi_agenta``/``claude``. A bare ``"pi"`` no longer parses (``HarnessType("pi")`` +# note): the wire moved from ``pi``/``agenta``/``claude`` to today's ``HarnessKind`` values +# ``pi_core``/``pi_agenta``/``claude``. A bare ``"pi"`` no longer parses (``HarnessKind("pi")`` # raises), so this table is required, not cosmetic. _HARNESS_RENAME = {"pi": "pi_core", "agenta": "pi_agenta", "claude": "claude"} diff --git a/sdks/python/oss/tests/pytest/unit/agents/adapters/test_vercel_stream_park.py b/sdks/python/oss/tests/pytest/unit/agents/adapters/test_vercel_stream_park.py index c9204c038e..eac1241c08 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/adapters/test_vercel_stream_park.py +++ b/sdks/python/oss/tests/pytest/unit/agents/adapters/test_vercel_stream_park.py @@ -84,6 +84,87 @@ async def test_parked_run_emits_approval_then_finish() -> None: assert finish["finishReason"] == "other" +def _two_concurrent_gates_run() -> AgentStream: + """One turn that raises TWO approval gates at once (concurrent approvals): two distinct tool + calls, each followed by its own ``user_approval`` request, then the terminal ``paused`` result. + The egress must surface one ``tool-approval-request`` per gate, each keyed to its own tool-call + id, and still drain to a single clean finish.""" + return AgentStream( + _records( + [ + { + "kind": "event", + "event": { + "type": "tool_call", + "id": "tool-1", + "name": "github__get_user", + "input": {}, + }, + }, + { + "kind": "event", + "event": { + "type": "tool_call", + "id": "tool-2", + "name": "github__list_repos", + "input": {}, + }, + }, + { + "kind": "event", + "event": { + "type": "interaction_request", + "id": "perm-1", + "kind": "user_approval", + "payload": {"toolCallId": "tool-1"}, + }, + }, + { + "kind": "event", + "event": { + "type": "interaction_request", + "id": "perm-2", + "kind": "user_approval", + "payload": {"toolCallId": "tool-2"}, + }, + }, + {"kind": "event", "event": {"type": "done", "stopReason": "paused"}}, + { + "kind": "result", + "result": { + "ok": True, + "output": "", + "stopReason": "paused", + "sessionId": "conv-1", + "traceId": "trace-1", + }, + }, + ] + ) + ) + + +@pytest.mark.asyncio +async def test_concurrent_gates_emit_one_approval_request_each() -> None: + """Two ``user_approval`` events in one turn yield TWO ``tool-approval-request`` frames — one + per event — each carrying its own approvalId and tool-call id, so the FE can prompt for both + gates independently. Pins the plural side of the single-gate contract above.""" + parts = [ + part async for part in agent_run_to_vercel_parts(_two_concurrent_gates_run()) + ] + + approvals = [p for p in parts if p["type"] == "tool-approval-request"] + assert len(approvals) == 2 + # One frame per event, each keyed to its own gate (perm/tool pair), order preserved. + assert [(a["approvalId"], a["toolCallId"]) for a in approvals] == [ + ("perm-1", "tool-1"), + ("perm-2", "tool-2"), + ] + # Both gates share the turn but the stream still drains to a single clean finish (F-040). + assert parts[-1]["type"] == "finish" + assert [p.get("type") for p in parts].count("finish") == 1 + + def _parked_run_with_real_args() -> AgentStream: """A parked turn where the runner first surfaced the tool call with EMPTY input, then the approval request carries the REAL args on ``payload.toolCall.rawInput`` (the cold-replay diff --git a/sdks/python/oss/tests/pytest/unit/agents/conftest.py b/sdks/python/oss/tests/pytest/unit/agents/conftest.py index c319e04fff..e5375b6d05 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/conftest.py +++ b/sdks/python/oss/tests/pytest/unit/agents/conftest.py @@ -21,7 +21,7 @@ from agenta.sdk.agents import ( AgentResult, Environment, - HarnessType, + HarnessKind, ) from agenta.sdk.agents.interfaces import Backend, Sandbox, Session from agenta.sdk.agents.streaming import AgentStream @@ -108,7 +108,7 @@ class FakeBackend(Backend): def __init__( self, *, - supported: Sequence[HarnessType] = (HarnessType.PI, HarnessType.CLAUDE), + supported: Sequence[HarnessKind] = (HarnessKind.PI, HarnessKind.CLAUDE), result: Optional[AgentResult] = None, result_session_id: Optional[str] = None, raise_on_prompt: bool = False, diff --git a/sdks/python/oss/tests/pytest/unit/agents/connections/test_dtos_model_ref.py b/sdks/python/oss/tests/pytest/unit/agents/connections/test_dtos_model_ref.py index 4d9ae6377f..0ff60ae8a6 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/connections/test_dtos_model_ref.py +++ b/sdks/python/oss/tests/pytest/unit/agents/connections/test_dtos_model_ref.py @@ -11,7 +11,7 @@ from agenta.sdk.agents import ( AgentTemplate, Connection, - HarnessType, + HarnessKind, Message, ModelRef, PiAgentTemplate, @@ -105,7 +105,7 @@ def test_wire_model_ref_emits_self_managed_connection_without_slug(): def test_string_only_config_wire_has_no_new_keys(): # The whole point of Slice 1: a string-only config's payload gains no new keys. payload = request_to_wire( - harness=HarnessType.PI, + harness=HarnessKind.PI, sandbox="local", config=PiAgentTemplate(model="openai-codex/gpt-5.5"), messages=[Message(role="user", content="hi")], @@ -117,7 +117,7 @@ def test_string_only_config_wire_has_no_new_keys(): def test_structured_config_wire_carries_provider_and_connection(): payload = request_to_wire( - harness=HarnessType.PI, + harness=HarnessKind.PI, sandbox="local", config=PiAgentTemplate( model={ diff --git a/sdks/python/oss/tests/pytest/unit/agents/skills/test_skills_e2e.py b/sdks/python/oss/tests/pytest/unit/agents/skills/test_skills_e2e.py index f5e85c308a..fee54841c9 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/skills/test_skills_e2e.py +++ b/sdks/python/oss/tests/pytest/unit/agents/skills/test_skills_e2e.py @@ -26,7 +26,7 @@ from agenta.sdk.agents import ( AgentTemplate, Environment, - HarnessType, + HarnessKind, Message, PiHarness, SessionConfig, @@ -55,7 +55,7 @@ def _pi_wire(env: Environment, agent: AgentTemplate) -> dict: harness = PiHarness(env) pi_config = harness._to_harness_config(SessionConfig(agent=agent)) return request_to_wire( - harness=HarnessType.PI, + harness=HarnessKind.PI, sandbox="local", config=pi_config, messages=[Message(role="user", content="ship it")], @@ -66,7 +66,7 @@ def _pi_wire(env: Environment, agent: AgentTemplate) -> dict: def test_inline_skill_materializes_on_the_wire(make_env): - env = make_env(supported=[HarnessType.PI]) + env = make_env(supported=[HarnessKind.PI]) agent = AgentTemplate(instructions="hi", model="gpt-5.5", skills=[_INLINE_SKILL]) wire = _pi_wire(env, agent) @@ -92,7 +92,7 @@ def test_inline_skill_materializes_on_the_wire(make_env): def test_minimal_inline_skill_omits_optional_flags_on_the_wire(make_env): - env = make_env(supported=[HarnessType.PI]) + env = make_env(supported=[HarnessKind.PI]) agent = AgentTemplate( instructions="hi", model="gpt-5.5", @@ -193,7 +193,7 @@ async def post(self, *args, **kwargs): # Now carry the resolved params the rest of the way, exactly as the handler does: build the # AgentTemplate from them, translate through the harness, and serialize the wire. - env = make_env(supported=[HarnessType.PI]) + env = make_env(supported=[HarnessKind.PI]) agent = AgentTemplate.from_params(request.data.parameters) wire = _pi_wire(env, agent) diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_agent_composition_seam.py b/sdks/python/oss/tests/pytest/unit/agents/test_agent_composition_seam.py index 57181dfaf6..b30691e652 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_agent_composition_seam.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_agent_composition_seam.py @@ -12,7 +12,7 @@ import pytest -from agenta.sdk.agents import AgentResult, HarnessType +from agenta.sdk.agents import AgentResult, HarnessKind from agenta.sdk.agents.connections import ( ConnectionResolutionError, ResolvedConnection, @@ -77,7 +77,7 @@ async def destroy(self) -> None: class _FakeBackend(Backend): supported_harnesses = frozenset( - {HarnessType.PI, HarnessType.CLAUDE, HarnessType.AGENTA} + {HarnessKind.PI, HarnessKind.CLAUDE, HarnessKind.AGENTA} ) def __init__(self, *, output: str = "hi") -> None: diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_dtos_capabilities_events.py b/sdks/python/oss/tests/pytest/unit/agents/test_dtos_capabilities_events.py index 253c0a1c35..0bf431aebb 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_dtos_capabilities_events.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_dtos_capabilities_events.py @@ -12,7 +12,7 @@ from agenta.sdk.agents import ( Event, HarnessCapabilities, - HarnessType, + HarnessKind, ToolCallback, TraceContext, ) @@ -78,10 +78,10 @@ def test_tool_callback_to_wire(): def test_harness_type_coerce(): - assert HarnessType.coerce(HarnessType.PI) is HarnessType.PI - assert HarnessType.coerce("pi_core") is HarnessType.PI - assert HarnessType.coerce("PI_CORE") is HarnessType.PI # case-insensitive - assert HarnessType.coerce("pi_agenta") is HarnessType.AGENTA - assert HarnessType.coerce("claude") is HarnessType.CLAUDE + assert HarnessKind.coerce(HarnessKind.PI) is HarnessKind.PI + assert HarnessKind.coerce("pi_core") is HarnessKind.PI + assert HarnessKind.coerce("PI_CORE") is HarnessKind.PI # case-insensitive + assert HarnessKind.coerce("pi_agenta") is HarnessKind.AGENTA + assert HarnessKind.coerce("claude") is HarnessKind.CLAUDE with pytest.raises(ValueError): - HarnessType.coerce("bogus") + HarnessKind.coerce("bogus") diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_environment_lifecycle.py b/sdks/python/oss/tests/pytest/unit/agents/test_environment_lifecycle.py index 4cfd442e3c..1672234daa 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_environment_lifecycle.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_environment_lifecycle.py @@ -13,7 +13,7 @@ AgentTemplate, AgentResult, ClaudeHarness, - HarnessType, + HarnessKind, Message, PiHarness, SessionConfig, @@ -33,12 +33,12 @@ async def test_fresh_sandbox_per_session(make_env): await env.create_session( PiHarness(env)._to_harness_config(config), - harness=HarnessType.PI, + harness=HarnessKind.PI, session_config=config, ) await env.create_session( PiHarness(env)._to_harness_config(config), - harness=HarnessType.PI, + harness=HarnessKind.PI, session_config=config, ) @@ -52,7 +52,7 @@ async def test_shared_sandbox_when_not_per_session(make_env): for _ in range(2): await env.create_session( PiHarness(env)._to_harness_config(config), - harness=HarnessType.PI, + harness=HarnessKind.PI, session_config=config, ) diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_harness_adapters.py b/sdks/python/oss/tests/pytest/unit/agents/test_harness_adapters.py index 4a29d4ad2a..bbaab7729b 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_harness_adapters.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_harness_adapters.py @@ -19,7 +19,7 @@ ClaudeAgentTemplate, ClaudeHarness, ClientToolSpec, - HarnessType, + HarnessKind, PiAgentTemplate, PiHarness, SessionConfig, @@ -50,7 +50,7 @@ def _session_config(**kwargs) -> SessionConfig: def test_pi_keeps_builtins_and_native_tools(make_env): - harness = PiHarness(make_env(supported=[HarnessType.PI])) + harness = PiHarness(make_env(supported=[HarnessKind.PI])) config = _session_config( builtin_tools=["read", "write"], custom_tools=[{"name": "t", "callRef": "ref"}], @@ -75,7 +75,7 @@ def test_pi_threads_model_ref_so_connection_reaches_wire(make_env): and the runner never sees the connection slug, so it cannot build its ``models.json`` plan for an OpenAI-compatible custom connection (the run then falls back to a default provider). """ - harness = PiHarness(make_env(supported=[HarnessType.PI])) + harness = PiHarness(make_env(supported=[HarnessKind.PI])) agent = AgentTemplate( instructions="hi", model={ @@ -97,7 +97,7 @@ def test_pi_threads_model_ref_so_connection_reaches_wire(make_env): def test_agenta_threads_model_ref_so_connection_reaches_wire(make_env): """Same guarantee as Pi for the ``pi_agenta`` harness (it also runs Pi).""" - harness = AgentaHarness(make_env(supported=[HarnessType.AGENTA])) + harness = AgentaHarness(make_env(supported=[HarnessKind.AGENTA])) agent = AgentTemplate( instructions="hi", model={ @@ -116,7 +116,7 @@ def test_agenta_threads_model_ref_so_connection_reaches_wire(make_env): def test_pi_reads_its_harness_extras_slice(make_env): - harness = PiHarness(make_env(supported=[HarnessType.PI])) + harness = PiHarness(make_env(supported=[HarnessKind.PI])) agent = AgentTemplate( instructions="hi", harness_extras={"system": "You are Pi.", "append_system": "Be terse."}, @@ -135,7 +135,7 @@ def test_pi_reads_its_harness_extras_slice(make_env): def test_pi_drops_blank_harness_extras(make_env): - harness = PiHarness(make_env(supported=[HarnessType.PI])) + harness = PiHarness(make_env(supported=[HarnessKind.PI])) agent = AgentTemplate( instructions="hi", harness_extras={"system": " ", "append_system": ""}, @@ -152,7 +152,7 @@ def test_pi_drops_blank_harness_extras(make_env): def test_agenta_forces_tools_preamble_and_persona_and_carries_skills(make_env): - harness = AgentaHarness(make_env(supported=[HarnessType.AGENTA])) + harness = AgentaHarness(make_env(supported=[HarnessKind.AGENTA])) skill = { "name": "release-notes", "description": "Draft release notes.", @@ -194,7 +194,7 @@ def test_agenta_forces_tools_preamble_and_persona_and_carries_skills(make_env): def test_agenta_forces_platform_skill_on_a_skill_less_config(make_env): # The actually-forced behavior: a custom pi_agenta config with NO skills (the default # template's `_agenta` embed dropped) still carries the platform skill on every run. - harness = AgentaHarness(make_env(supported=[HarnessType.AGENTA])) + harness = AgentaHarness(make_env(supported=[HarnessKind.AGENTA])) config = _session_config( agent=AgentTemplate(instructions="My project rules.", model="m", skills=[]) ) @@ -207,7 +207,7 @@ def test_agenta_forces_platform_skill_on_a_skill_less_config(make_env): def test_agenta_does_not_duplicate_an_already_present_platform_skill(make_env): # A config that already carries the resolved platform skill (e.g. via the default template's # embed) is not doubled: the author's copy wins on the name clash. - harness = AgentaHarness(make_env(supported=[HarnessType.AGENTA])) + harness = AgentaHarness(make_env(supported=[HarnessKind.AGENTA])) existing = GETTING_STARTED_WITH_AGENTA_SKILL.model_dump(mode="json") config = _session_config( agent=AgentTemplate(instructions="hi", model="m", skills=[existing]) @@ -235,7 +235,7 @@ def test_force_skills_unions_forced_after_author_skills(): def test_agenta_forces_tools_without_duplicates(make_env): - harness = AgentaHarness(make_env(supported=[HarnessType.AGENTA])) + harness = AgentaHarness(make_env(supported=[HarnessKind.AGENTA])) # `read` already configured: it must not be duplicated when forced. config = _session_config(builtin_tools=["read"]) @@ -245,7 +245,7 @@ def test_agenta_forces_tools_without_duplicates(make_env): def test_agenta_passes_through_user_pi_options(make_env): - harness = AgentaHarness(make_env(supported=[HarnessType.AGENTA])) + harness = AgentaHarness(make_env(supported=[HarnessKind.AGENTA])) agent = AgentTemplate( instructions="hi", harness_extras={"system": "You are Pi.", "append_system": "Be terse."}, @@ -265,7 +265,7 @@ def test_agenta_is_sandbox_agent_supported(): # `agenta` run on a non-local sandbox (e.g. daytona) instead of raising. from agenta.sdk.agents import SandboxAgentBackend - assert SandboxAgentBackend(url="http://runner").supports(HarnessType.AGENTA) + assert SandboxAgentBackend(url="http://runner").supports(HarnessKind.AGENTA) # ------------------------------------------------------------------------- Claude @@ -278,7 +278,7 @@ def test_claude_drops_builtins_and_warns(make_env, monkeypatch): "log", type("L", (), {"warning": lambda self, *a, **k: recorded.append(a)})(), ) - harness = ClaudeHarness(make_env(supported=[HarnessType.CLAUDE])) + harness = ClaudeHarness(make_env(supported=[HarnessKind.CLAUDE])) config = _session_config( builtin_tools=["read"], custom_tools=[{"name": "t", "callRef": "ref"}], @@ -295,7 +295,7 @@ def test_claude_drops_builtins_and_warns(make_env, monkeypatch): def test_claude_carries_skills_for_project_local_materialization(make_env): - harness = ClaudeHarness(make_env(supported=[HarnessType.CLAUDE])) + harness = ClaudeHarness(make_env(supported=[HarnessKind.CLAUDE])) skill = { "name": "release-notes", "description": "Draft release notes.", @@ -320,7 +320,7 @@ def test_claude_no_warning_without_builtins(make_env, monkeypatch): "log", type("L", (), {"warning": lambda self, *a, **k: recorded.append(a)})(), ) - harness = ClaudeHarness(make_env(supported=[HarnessType.CLAUDE])) + harness = ClaudeHarness(make_env(supported=[HarnessKind.CLAUDE])) harness._to_harness_config(_session_config(permission_default="allow_reads")) @@ -330,7 +330,7 @@ def test_claude_no_warning_without_builtins(make_env, monkeypatch): def test_claude_threads_permissions_and_renders_settings_file(make_env): import json - harness = ClaudeHarness(make_env(supported=[HarnessType.CLAUDE])) + harness = ClaudeHarness(make_env(supported=[HarnessKind.CLAUDE])) permissions = { "default_mode": "acceptEdits", "allow": ["Read"], @@ -355,7 +355,7 @@ def test_claude_threads_permissions_and_renders_settings_file(make_env): def test_claude_without_permissions_renders_no_files(make_env): - harness = ClaudeHarness(make_env(supported=[HarnessType.CLAUDE])) + harness = ClaudeHarness(make_env(supported=[HarnessKind.CLAUDE])) result = harness._to_harness_config(_session_config()) @@ -390,7 +390,7 @@ def test_compat_normalize_tool_specs_returns_typed_specs(): def test_harness_accepts_typed_tool_specs_without_normalizing_dicts(make_env): - harness = PiHarness(make_env(supported=[HarnessType.PI])) + harness = PiHarness(make_env(supported=[HarnessKind.PI])) spec = ClientToolSpec(name="pick", description="Pick") result = harness._to_harness_config(_session_config(tool_specs=[spec])) assert result.tool_specs == [spec] @@ -413,24 +413,24 @@ def test_opt_str_keeps_only_nonempty_strings(): def test_make_harness_maps_string_to_class(make_env): - env = make_env(supported=[HarnessType.PI, HarnessType.CLAUDE, HarnessType.AGENTA]) + env = make_env(supported=[HarnessKind.PI, HarnessKind.CLAUDE, HarnessKind.AGENTA]) assert isinstance(make_harness("pi_core", env), PiHarness) assert isinstance( make_harness("PI_CORE", env), PiHarness ) # coerced, case-insensitive assert isinstance(make_harness("claude", env), ClaudeHarness) - assert isinstance(make_harness(HarnessType.CLAUDE, env), ClaudeHarness) + assert isinstance(make_harness(HarnessKind.CLAUDE, env), ClaudeHarness) assert isinstance(make_harness("pi_agenta", env), AgentaHarness) - assert isinstance(make_harness(HarnessType.AGENTA, env), AgentaHarness) + assert isinstance(make_harness(HarnessKind.AGENTA, env), AgentaHarness) def test_make_harness_unsupported_backend_raises(make_env): - env = make_env(supported=[HarnessType.PI]) # backend cannot drive Claude + env = make_env(supported=[HarnessKind.PI]) # backend cannot drive Claude with pytest.raises(UnsupportedHarnessError): make_harness("claude", env) def test_make_harness_unknown_name_raises(make_env): - env = make_env(supported=[HarnessType.PI]) + env = make_env(supported=[HarnessKind.PI]) with pytest.raises(ValueError): make_harness("bogus", env) diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_harness_identity.py b/sdks/python/oss/tests/pytest/unit/agents/test_harness_identity.py index 5b1726f577..90c70f0366 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_harness_identity.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_harness_identity.py @@ -9,14 +9,14 @@ from __future__ import annotations -from agenta.sdk.agents import HARNESS_IDENTITIES, HarnessType +from agenta.sdk.agents import HARNESS_IDENTITIES, HarnessKind from agenta.sdk.utils.types import CATALOG_TYPES def test_one_identity_per_harness_type(): - # Every HarnessType value has exactly one identity, and nothing extra. + # Every HarnessKind value has exactly one identity, and nothing extra. by_value = {identity.value: identity for identity in HARNESS_IDENTITIES} - assert set(by_value) == {h.value for h in HarnessType} + assert set(by_value) == {h.value for h in HarnessKind} assert len(by_value) == len(HARNESS_IDENTITIES) @@ -28,7 +28,7 @@ def test_slug_follows_the_repo_versioned_slug_grammar(): def test_identity_value_is_the_bare_harness_string(): - # The identity's `value` is the bare HarnessType value (the runtime/wire selector), NOT the + # The identity's `value` is the bare HarnessKind value (the runtime/wire selector), NOT the # slug — so the wire/runner contract is unchanged. values = {identity.value for identity in HARNESS_IDENTITIES} assert values == {"pi_core", "pi_agenta", "claude"} diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_ui_messages.py b/sdks/python/oss/tests/pytest/unit/agents/test_ui_messages.py index b4e06b53fa..baa5453a52 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_ui_messages.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_ui_messages.py @@ -212,7 +212,10 @@ def test_inline_approval_responded_deny_emits_approved_envelope(self): "type": "tool_result", "toolCallId": "call_1", "toolName": "deleteFile", - "output": {"approved": False}, + "output": { + "approved": False, + "interactionToken": "perm_1", + }, }, ] @@ -238,7 +241,51 @@ def test_inline_approval_responded_approve_emits_approved_envelope(self): result = msgs[0].content[1] assert result.type == "tool_result" assert result.tool_call_id == "call_2" - assert result.output == {"approved": True} + assert result.output == { + "approved": True, + "interactionToken": "perm_2", + } + + def test_concurrent_inline_approvals_each_emit_their_own_result(self): + # Concurrent approvals: the browser round-trips TWO tool parts in one turn, each with its + # own inline decision (`state: approval-responded`, `approval.approved`) — one approve, one + # deny. The ingress must emit one `{approved}` tool_result per part, keyed by its own + # toolCallId, preserving each decision independently (neither gate clobbers the other). + msgs = vercel_ui_messages_to_messages( + [ + { + "id": "m10", + "role": "assistant", + "parts": [ + { + "type": "tool-deleteFile", + "toolCallId": "call_1", + "state": "approval-responded", + "input": {"path": "/a"}, + "approval": {"id": "perm_1", "approved": True}, + }, + { + "type": "tool-writeFile", + "toolCallId": "call_2", + "state": "approval-responded", + "input": {"path": "/b"}, + "approval": {"id": "perm_2", "approved": False}, + }, + ], + } + ] + ) + results = [b for b in msgs[0].content if b.type == "tool_result"] + assert len(results) == 2 + # Each decision lands on its own toolCallId, in order, approve and deny preserved. + assert (results[0].tool_call_id, results[0].output) == ( + "call_1", + {"approved": True, "interactionToken": "perm_1"}, + ) + assert (results[1].tool_call_id, results[1].output) == ( + "call_2", + {"approved": False, "interactionToken": "perm_2"}, + ) def test_inline_output_denied_state_emits_denied_envelope(self): # The AI SDK terminal deny state has no `approval.approved` flag; `output-denied` @@ -263,7 +310,10 @@ def test_inline_output_denied_state_emits_denied_envelope(self): result = msgs[0].content[1] assert result.type == "tool_result" assert result.tool_call_id == "call_3" - assert result.output == {"approved": False} + assert result.output == { + "approved": False, + "interactionToken": "perm_3", + } def test_pending_approval_request_only_part_emits_no_decision(self): # A tool part still awaiting a decision (`approval-requested`, no `approval.approved`) diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py b/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py index 0c73b519c4..f3fb58e528 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py @@ -23,7 +23,7 @@ AgentaAgentTemplate, ClaudeAgentTemplate, Endpoint, - HarnessType, + HarnessKind, Message, PiAgentTemplate, ResolvedConnection, @@ -131,7 +131,7 @@ def _pi_payload(): append_system="Be terse.", ) return request_to_wire( - harness=HarnessType.PI, + harness=HarnessKind.PI, sandbox="local", config=config, messages=[Message(role="user", content="hi")], @@ -179,7 +179,7 @@ def _claude_payload(): }, ) return request_to_wire( - harness=HarnessType.CLAUDE, + harness=HarnessKind.CLAUDE, sandbox="local", config=config, messages=[Message(role="user", content="hi")], @@ -201,7 +201,7 @@ def _agenta_payload(): skills=[dict(_SKILL)], ) return request_to_wire( - harness=HarnessType.AGENTA, + harness=HarnessKind.AGENTA, sandbox="local", config=config, messages=[Message(role="user", content="hi")], @@ -230,7 +230,7 @@ def test_request_to_wire_skills_ride_their_own_seam_not_tools(): def test_request_to_wire_omits_skills_when_none(): # No declared skills -> no `skills` key (keeps a skill-free payload byte-identical). payload = request_to_wire( - harness=HarnessType.PI, + harness=HarnessKind.PI, sandbox="local", config=PiAgentTemplate(), messages=[Message(role="user", content="hi")], @@ -313,7 +313,7 @@ def test_request_to_wire_omits_run_context_when_none(): # No run context passed -> no `runContext` key (a run that needs no `call.context` binding stays # byte-identical to before, the same discipline skills/mcpServers/sandboxPermission use). payload = request_to_wire( - harness=HarnessType.PI, + harness=HarnessKind.PI, sandbox="local", config=PiAgentTemplate(), messages=[Message(role="user", content="hi")], @@ -325,7 +325,7 @@ def test_request_to_wire_omits_run_context_when_empty(): # An entirely-empty run context (no identity to bind) serializes to {} and is dropped, so it # never rides the wire as a noise `"runContext": {}` key. payload = request_to_wire( - harness=HarnessType.PI, + harness=HarnessKind.PI, sandbox="local", config=PiAgentTemplate(), messages=[Message(role="user", content="hi")], @@ -336,7 +336,7 @@ def test_request_to_wire_omits_run_context_when_empty(): def test_request_to_wire_carries_turn_id_when_set(): payload = request_to_wire( - harness=HarnessType.PI, + harness=HarnessKind.PI, sandbox="local", config=PiAgentTemplate(), messages=[Message(role="user", content="hi")], @@ -348,7 +348,7 @@ def test_request_to_wire_carries_turn_id_when_set(): def test_request_to_wire_omits_turn_id_when_none(): payload = request_to_wire( - harness=HarnessType.PI, + harness=HarnessKind.PI, sandbox="local", config=PiAgentTemplate(), messages=[Message(role="user", content="hi")], @@ -358,7 +358,7 @@ def test_request_to_wire_omits_turn_id_when_none(): def test_request_to_wire_carries_project_id_when_set(): payload = request_to_wire( - harness=HarnessType.PI, + harness=HarnessKind.PI, sandbox="local", config=PiAgentTemplate(), messages=[Message(role="user", content="hi")], @@ -370,7 +370,7 @@ def test_request_to_wire_carries_project_id_when_set(): def test_request_to_wire_omits_project_id_when_none(): payload = request_to_wire( - harness=HarnessType.PI, + harness=HarnessKind.PI, sandbox="local", config=PiAgentTemplate(), messages=[Message(role="user", content="hi")], @@ -442,7 +442,7 @@ def test_author_permission_rules_exclude_mcp_from_wire_but_keep_settings(): } ) payload = request_to_wire( - harness=HarnessType.CLAUDE, + harness=HarnessKind.CLAUDE, sandbox="local", config=config, messages=[Message(role="user", content="hi")], @@ -472,7 +472,7 @@ def test_request_to_wire_has_no_prompt_key(): # The serializer emits `messages` only; the TS side derives the latest turn with # `resolvePromptText`. This asymmetry is intentional and easy to break, so lock it. payload = request_to_wire( - harness=HarnessType.PI, + harness=HarnessKind.PI, sandbox="local", config=PiAgentTemplate(), messages=[Message(role="user", content="hi")], @@ -507,7 +507,7 @@ def test_request_to_wire_carries_resolved_connection_non_secret_descriptor(): ), ) payload = request_to_wire( - harness=HarnessType.PI, + harness=HarnessKind.PI, sandbox="local", config=config, messages=[Message(role="user", content="hi")], @@ -533,7 +533,7 @@ def test_request_to_wire_omits_resolved_connection_when_none(): # byte-identical to before (the golden contract; the golden fixtures set none). config = PiAgentTemplate(model="gpt-5.5") payload = request_to_wire( - harness=HarnessType.PI, + harness=HarnessKind.PI, sandbox="local", config=config, messages=[Message(role="user", content="hi")], @@ -549,7 +549,7 @@ def test_request_to_wire_omits_resolved_connection_when_none(): def test_pi_permissions_default_to_allow_reads(): # Pi ships the same default permission plan as the other harnesses. payload = request_to_wire( - harness=HarnessType.PI, + harness=HarnessKind.PI, sandbox="local", config=PiAgentTemplate(), messages=[Message(role="user", content="hi")], @@ -674,7 +674,7 @@ def test_request_to_wire_carries_code_client_and_mcp_specs(): ], ) payload = request_to_wire( - harness=HarnessType.PI, + harness=HarnessKind.PI, sandbox="local", config=config, messages=[Message(role="user", content="hi")], @@ -706,7 +706,7 @@ def test_request_to_wire_carries_code_client_and_mcp_specs(): def test_request_to_wire_omits_mcp_servers_when_none(): # No declared servers -> no `mcpServers` key (keeps a tool-free payload byte-identical). payload = request_to_wire( - harness=HarnessType.PI, + harness=HarnessKind.PI, sandbox="local", config=PiAgentTemplate(), messages=[Message(role="user", content="hi")], @@ -718,7 +718,7 @@ def test_request_to_wire_omits_sandbox_permission_when_none(): # No declared boundary -> no `sandboxPermission` key (keeps a boundary-free payload # byte-identical, so existing configs/fixtures are unaffected). payload = request_to_wire( - harness=HarnessType.PI, + harness=HarnessKind.PI, sandbox="local", config=PiAgentTemplate(), messages=[Message(role="user", content="hi")], @@ -730,7 +730,7 @@ def test_request_to_wire_omits_harness_files_when_none(): # No authored options on a Claude config -> the claude adapter renders nothing, so no # `harnessFiles` key (a Claude run without harness options is byte-identical to before). payload = request_to_wire( - harness=HarnessType.CLAUDE, + harness=HarnessKind.CLAUDE, sandbox="local", config=ClaudeAgentTemplate(), messages=[Message(role="user", content="hi")], @@ -744,7 +744,7 @@ def test_request_to_wire_pi_renders_no_harness_files_from_its_options(): # `appendSystemPrompt`, not a file). config = PiAgentTemplate(system="You are Pi.") payload = request_to_wire( - harness=HarnessType.PI, + harness=HarnessKind.PI, sandbox="local", config=config, messages=[Message(role="user", content="hi")], @@ -771,7 +771,7 @@ def test_request_to_wire_claude_renders_settings_from_options_and_boundaries(): ], ) payload = request_to_wire( - harness=HarnessType.CLAUDE, + harness=HarnessKind.CLAUDE, sandbox="local", config=config, messages=[Message(role="user", content="hi")], @@ -796,7 +796,7 @@ def test_request_to_wire_carries_sandbox_permission_allowlist(): ) ) payload = request_to_wire( - harness=HarnessType.PI, + harness=HarnessKind.PI, sandbox="local", config=config, messages=[Message(role="user", content="hi")], diff --git a/sdks/python/oss/tests/pytest/unit/test_batch_fold_stream_contract_routing.py b/sdks/python/oss/tests/pytest/unit/test_batch_fold_stream_contract_routing.py index 0282e08125..157a440097 100644 --- a/sdks/python/oss/tests/pytest/unit/test_batch_fold_stream_contract_routing.py +++ b/sdks/python/oss/tests/pytest/unit/test_batch_fold_stream_contract_routing.py @@ -26,7 +26,7 @@ from fastapi import FastAPI from fastapi.testclient import TestClient -from agenta.sdk.agents import AgentResult, HarnessType +from agenta.sdk.agents import AgentResult, HarnessKind from agenta.sdk.agents.dtos import Event from agenta.sdk.agents.fold import fold from agenta.sdk.agents.handler import AgentComposition, make_agent_handler @@ -123,7 +123,7 @@ async def destroy(self) -> None: class _FakeBackend(Backend): supported_harnesses = frozenset( - {HarnessType.PI, HarnessType.CLAUDE, HarnessType.AGENTA} + {HarnessKind.PI, HarnessKind.CLAUDE, HarnessKind.AGENTA} ) def __init__(self, *, events: List[Event]) -> None: diff --git a/sdks/python/oss/tests/pytest/unit/test_invoke_real_handlers_negotiation_routing.py b/sdks/python/oss/tests/pytest/unit/test_invoke_real_handlers_negotiation_routing.py index 5ba8282e59..00775f56ab 100644 --- a/sdks/python/oss/tests/pytest/unit/test_invoke_real_handlers_negotiation_routing.py +++ b/sdks/python/oss/tests/pytest/unit/test_invoke_real_handlers_negotiation_routing.py @@ -35,7 +35,7 @@ from fastapi import FastAPI from fastapi.testclient import TestClient -from agenta.sdk.agents import AgentResult, HarnessType +from agenta.sdk.agents import AgentResult, HarnessKind from agenta.sdk.agents.dtos import Event from agenta.sdk.agents.handler import AgentComposition, make_agent_handler from agenta.sdk.agents.interfaces import Backend, Sandbox, Session @@ -126,7 +126,7 @@ async def destroy(self) -> None: class _FakeBackend(Backend): supported_harnesses = frozenset( - {HarnessType.PI, HarnessType.CLAUDE, HarnessType.AGENTA} + {HarnessKind.PI, HarnessKind.CLAUDE, HarnessKind.AGENTA} ) def __init__(self, *, events: List[Event], output: str = "") -> None: diff --git a/sdks/python/oss/tests/pytest/unit/test_normalizer_passthrough.py b/sdks/python/oss/tests/pytest/unit/test_normalizer_passthrough.py index 56b9ef82c9..f3dbb09f68 100644 --- a/sdks/python/oss/tests/pytest/unit/test_normalizer_passthrough.py +++ b/sdks/python/oss/tests/pytest/unit/test_normalizer_passthrough.py @@ -161,6 +161,102 @@ def handler(session_id): assert request.session_id == sid assert response.session_id == sid + @pytest.mark.asyncio + async def test_call_stores_agent_id_from_running_context_revision( + self, monkeypatch + ): + import agenta as ag + + class _TracingSpy: + def __init__(self): + self.store_session_calls = [] + self.store_agent_calls = [] + + def store_session(self, session_id): + self.store_session_calls.append(session_id) + + def store_agent(self, agent_id): + self.store_agent_calls.append(agent_id) + + spy = _TracingSpy() + monkeypatch.setattr(ag, "tracing", spy, raising=False) + + def handler(request): + return {"ok": True} + + request = WorkflowServiceRequest( + session_id="sess_agent_test", + data=WorkflowRequestData(), + ) + + token = RunningContext.set( + RunningContext( + handler=handler, + revision={"artifact_id": "workflow-abc"}, + ) + ) + try: + await NormalizerMiddleware()(request, lambda req: None) + finally: + RunningContext.reset(token) + + assert spy.store_agent_calls == ["workflow-abc"] + assert spy.store_session_calls == ["sess_agent_test"] + + @pytest.mark.asyncio + async def test_call_does_not_store_agent_id_when_revision_missing( + self, monkeypatch + ): + import agenta as ag + + class _TracingSpy: + def __init__(self): + self.store_agent_calls = [] + + def store_session(self, session_id): + pass + + def store_agent(self, agent_id): + self.store_agent_calls.append(agent_id) + + spy = _TracingSpy() + monkeypatch.setattr(ag, "tracing", spy, raising=False) + + def handler(request): + return {"ok": True} + + request = WorkflowServiceRequest(data=WorkflowRequestData()) + + token = RunningContext.set(RunningContext(handler=handler)) + try: + await NormalizerMiddleware()(request, lambda req: None) + finally: + RunningContext.reset(token) + + assert spy.store_agent_calls == [] + + +class TestResolveAgentId: + def test_resolves_artifact_id_from_revision(self): + ctx = RunningContext(revision={"artifact_id": "wf-1"}) + assert NormalizerMiddleware._resolve_agent_id(ctx) == "wf-1" + + def test_falls_back_to_workflow_id_alias(self): + ctx = RunningContext(revision={"workflow_id": "wf-2"}) + assert NormalizerMiddleware._resolve_agent_id(ctx) == "wf-2" + + def test_none_when_revision_missing(self): + ctx = RunningContext() + assert NormalizerMiddleware._resolve_agent_id(ctx) is None + + def test_none_when_revision_not_a_dict(self): + ctx = RunningContext.model_construct(revision="not-a-dict") + assert NormalizerMiddleware._resolve_agent_id(ctx) is None + + def test_none_when_artifact_id_absent(self): + ctx = RunningContext(revision={"id": "rev-1"}) + assert NormalizerMiddleware._resolve_agent_id(ctx) is None + class TestAsyncGenerator: @pytest.mark.asyncio diff --git a/sdks/python/oss/tests/pytest/unit/test_tracing_store_agent.py b/sdks/python/oss/tests/pytest/unit/test_tracing_store_agent.py new file mode 100644 index 0000000000..1b1133f160 --- /dev/null +++ b/sdks/python/oss/tests/pytest/unit/test_tracing_store_agent.py @@ -0,0 +1,33 @@ +"""Unit tests for Tracing.store_agent (mirrors store_session/store_user). + +Verifies the attribute is set on the span with the `agent` namespace and that a +falsy agent_id is a no-op. +""" + +from unittest.mock import MagicMock + +import pytest + +from agenta.sdk.engines.tracing.tracing import Tracing + + +@pytest.fixture +def tracing(): + return Tracing(url="http://localhost:4318/v1/traces") + + +def test_store_agent_sets_id_attribute_on_given_span(tracing): + span = MagicMock() + + tracing.store_agent(agent_id="agent-123", span=span) + + span.set_attribute.assert_called_once_with("id", "agent-123", namespace="agent") + + +def test_store_agent_is_noop_when_agent_id_falsy(tracing): + span = MagicMock() + + tracing.store_agent(agent_id=None, span=span) + tracing.store_agent(agent_id="", span=span) + + span.set_attribute.assert_not_called() diff --git a/sdks/python/oss/tests/pytest/unit/test_workflow_control_running.py b/sdks/python/oss/tests/pytest/unit/test_workflow_control_running.py index ab425f250e..17a7c64d43 100644 --- a/sdks/python/oss/tests/pytest/unit/test_workflow_control_running.py +++ b/sdks/python/oss/tests/pytest/unit/test_workflow_control_running.py @@ -117,7 +117,6 @@ def row(self, sid: str) -> dict: @pytest.mark.asyncio async def test_send_on_idle_session_marks_alive_and_increments_token(): - store = _FakeSessionStore() started = asyncio.Event() @@ -142,7 +141,6 @@ async def slow_run(): @pytest.mark.asyncio async def test_send_collision_when_alive_without_control_is_409(): - store = _FakeSessionStore() store.row("s1")["alive"] = True @@ -154,7 +152,6 @@ async def test_send_collision_when_alive_without_control_is_409(): @pytest.mark.asyncio async def test_steer_cancels_alive_run_and_restarts(): - store = _FakeSessionStore() row = store.row("s1") row["alive"] = True @@ -176,7 +173,6 @@ async def new_run(): @pytest.mark.asyncio async def test_cancel_marks_dead(): - store = _FakeSessionStore() row = store.row("s1") row["alive"] = True @@ -194,7 +190,6 @@ async def test_cancel_marks_dead(): @pytest.mark.asyncio async def test_attach_to_alive_detached_run(): - store = _FakeSessionStore() row = store.row("s1") row["alive"] = True @@ -211,7 +206,6 @@ async def test_attach_to_alive_detached_run(): @pytest.mark.asyncio async def test_detach_on_client_disconnect_keeps_run_alive(): - store = _FakeSessionStore() row = store.row("s1") row["alive"] = True diff --git a/services/oss/tests/pytest/unit/agent/conftest.py b/services/oss/tests/pytest/unit/agent/conftest.py index b5da23e063..7e0e6b9f1b 100644 --- a/services/oss/tests/pytest/unit/agent/conftest.py +++ b/services/oss/tests/pytest/unit/agent/conftest.py @@ -14,7 +14,7 @@ import pytest -from agenta.sdk.agents import AgentResult, HarnessType +from agenta.sdk.agents import AgentResult, HarnessKind from agenta.sdk.agents.interfaces import Backend, Sandbox, Session from agenta.sdk.agents.streaming import AgentStream @@ -87,10 +87,10 @@ def __init__( self, *, result: Optional[AgentResult] = None, - supported: Sequence[HarnessType] = ( - HarnessType.PI, - HarnessType.CLAUDE, - HarnessType.AGENTA, + supported: Sequence[HarnessKind] = ( + HarnessKind.PI, + HarnessKind.CLAUDE, + HarnessKind.AGENTA, ), ) -> None: self.supported_harnesses = frozenset(supported) diff --git a/services/runner/src/engines/sandbox_agent/acp-interactions.ts b/services/runner/src/engines/sandbox_agent/acp-interactions.ts index 89cd808360..3f04a0eecb 100644 --- a/services/runner/src/engines/sandbox_agent/acp-interactions.ts +++ b/services/runner/src/engines/sandbox_agent/acp-interactions.ts @@ -11,7 +11,6 @@ import { } from "../../responder.ts"; import { piBuiltinIdentity, - PendingApprovalLatch, type GateDescriptor, } from "../../permission-plan.ts"; import { @@ -44,7 +43,6 @@ export interface AttachPermissionResponderInput { markToolCallDenied?: (toolCallId: string | undefined) => void; }; responder: Responder; - latch: PendingApprovalLatch; serverPermissions?: ReadonlyMap; /** * Called when a gate pauses the turn. The orchestration loop uses this to end the turn @@ -54,6 +52,17 @@ export interface AttachPermissionResponderInput { log?: (msg: string) => void; /** Called with the ACP tool-call id when a gate pauses the turn. */ onPausedToolCall?: (id: string) => void; + /** Called before an allow reply can release the harness to execute this tool call. */ + onAllowedExecution?: (id: string) => void; + /** Called before a deny reply so its failed terminal frame remains authoritative. */ + onAnsweredDeny?: (id: string) => void; + /** + * Called when a NON-parkable pause happens this turn (a client-tool ACP gate, which cannot be + * answered across a turn boundary on the live session). The keep-alive dispatch reads this to + * keep a turn that mixes an approval gate with a client-tool pause on the cold path, since only + * the cold path can multiplex that mixed set. An approval gate does NOT fire it. + */ + onNonParkablePause?: () => void; /** Called on pause to record the pending gate as an interaction (fire-and-forget). */ onCreateInteraction?: ( token: string, @@ -62,13 +71,16 @@ export interface AttachPermissionResponderInput { kind: "user_approval" | "client_tool", ) => void; /** Called after a stored decision was successfully forwarded to the harness. */ - onResolveInteraction?: (token: string) => void; + onResolveInteraction?: ( + token: string, + verdict?: { approved: boolean; toolCallId: string }, + ) => void; /** * Fires for EVERY parkable permission gate (a Claude ACP gate or a Pi ACP gate) that - * resolves to pendingApproval, BEFORE the single-pause latch. Keep-alive uses it to record - * the parked permission id / tool-call id (for a live resume via `respondPermission`) and to - * count how many gates are pending this turn (a multi-gate pause does not park). It never - * fires for a client-tool gate (`pauseClientTool`), which stays on the cold path. + * resolves to pendingApproval. Keep-alive uses it to record every parked permission id / + * tool-call id (for a live resume via `respondPermission`, keyed by tool-call id) and to count + * how many gates are pending this turn. It never fires for a client-tool gate + * (`pauseClientTool`), which fires `onNonParkablePause` instead and stays on the cold path. */ onUserApprovalGate?: (info: { permissionId: string; @@ -109,11 +121,13 @@ export function attachPermissionResponder({ session, run, responder, - latch, serverPermissions = new Map(), onPause, log, onPausedToolCall, + onAllowedExecution, + onAnsweredDeny, + onNonParkablePause, onCreateInteraction, onResolveInteraction, onUserApprovalGate, @@ -155,19 +169,18 @@ export function attachPermissionResponder({ gate: GateDescriptor, gateType: ParkedApprovalGateType, ): void => { - // Signal the parkable gate BEFORE the latch so a keep-alive resume can record the pending - // permission id and the multi-gate detector counts every pending gate (not just the first). - const gateToolCallId = stringValue(req?.toolCall?.toolCallId); + // Signal the parkable gate so a keep-alive resume can record the pending permission id and + // count every pending gate. Each gate emits its own card: there is no per-turn cap, so N + // gated calls in one turn all render and all park (the plural approval path). + const toolCallId = stringValue(req?.toolCall?.toolCallId); onUserApprovalGate?.({ permissionId: id, - toolCallId: gateToolCallId ?? "", + toolCallId: toolCallId ?? "", toolName: gate.toolName, args: gate.args, - interactionToken: interactionEventId(id, gateToolCallId), + interactionToken: interactionEventId(id, toolCallId), gateType, }); - if (!latch.tryAcquire()) return; - const toolCallId = stringValue(req?.toolCall?.toolCallId); const eventId = interactionEventId(id, toolCallId); if (toolCallId) onPausedToolCall?.(toolCallId); run.emitEvent({ @@ -192,7 +205,9 @@ export function attachPermissionResponder({ gate: GateDescriptor, spec: ResolvedToolSpec, ): void => { - if (!latch.tryAcquire()) return; + // A client-tool ACP pause cannot be answered on the live session across a turn boundary, so + // flag the turn non-parkable: a turn that mixes this with an approval gate stays cold. + onNonParkablePause?.(); const toolCallId = stringValue(req?.toolCall?.toolCallId); const eventId = interactionEventId(id, toolCallId); if (toolCallId) onPausedToolCall?.(toolCallId); @@ -213,10 +228,15 @@ export function attachPermissionResponder({ onPause?.(); }; - // Resolve the durable row this gate created, if it created one. - const resolveIfCreated = (id: string): void => { - if (!createdInteractionIds.delete(id)) return; - onResolveInteraction?.(id); + // A cold responder has no local creation set, so its stored decision carries the original token. + const resolveAfterReply = ( + id: string, + verdict?: { approved: boolean; toolCallId: string }, + interactionToken?: string, + ): void => { + const token = createdInteractionIds.delete(id) ? id : interactionToken; + if (!token) return; + onResolveInteraction?.(token, verdict); }; const replyPermission = async ( @@ -224,12 +244,19 @@ export function attachPermissionResponder({ decision: PermissionDecision, availableReplies: string[], toolCallId?: string, + interactionToken?: string, ): Promise => { // A deny replies `reject`, which makes the harness close the call as a FAILED tool call. Flag // the id first so the closing result carries `denied` and the egress renders a decline, not a // breakage. (A malformed-envelope / unknown-builtin fail-closed reject goes through // `rejectRequest`, not here, so it stays a plain error — it is not a user/policy denial.) - if (decision === "deny") run.markToolCallDenied?.(toolCallId); + if (decision === "deny") { + run.markToolCallDenied?.(toolCallId); + if (toolCallId) onAnsweredDeny?.(toolCallId); + } + // Mark before replying because an allow can release the harness synchronously and its first + // execution frame must already be protected from a concurrently active pause sweep. + if (decision === "allow" && toolCallId) onAllowedExecution?.(toolCallId); try { await session.respondPermission( id, @@ -242,7 +269,14 @@ export function attachPermissionResponder({ onPause?.(); return; } - resolveIfCreated(id); + resolveAfterReply( + id, + { + approved: decision === "allow", + toolCallId: toolCallId ?? id, + }, + interactionToken, + ); }; const replyClientTool = async ( @@ -262,7 +296,7 @@ export function attachPermissionResponder({ onPause?.(); return; } - resolveIfCreated(id); + resolveAfterReply(id); }; // A bare reject that answers the harness WITHOUT touching the durable interactions plane (no @@ -348,7 +382,13 @@ export function attachPermissionResponder({ if (verdict.kind === "allow" && envelope.gate === "pi-custom-tool") { onPiGateAllowed?.({ toolName: gate.toolName!, args: gate.args }); } - await replyPermission(id, verdict.kind, availableReplies, envelope.toolCallId); + await replyPermission( + id, + verdict.kind, + availableReplies, + envelope.toolCallId, + verdict.interactionToken, + ); }; async function handleRequest(req: any): Promise { @@ -437,6 +477,7 @@ export function attachPermissionResponder({ verdict.kind, availableReplies, stringValue(toolCall?.toolCallId), + verdict.interactionToken, ); } } @@ -512,7 +553,7 @@ function recordedToolName( * strip that prefix so the lookup hits). This is the ONLY source of a tool's true * `permission`/`readOnly`, so both the approval card and this descriptor come from one lookup. */ -function buildGateDescriptor( +export function buildGateDescriptor( toolCall: any, run: { events?: () => AgentEvent[] }, serverPermissions: ReadonlyMap, diff --git a/services/runner/src/engines/sandbox_agent/client-tools.ts b/services/runner/src/engines/sandbox_agent/client-tools.ts index ee244aef59..492ead08de 100644 --- a/services/runner/src/engines/sandbox_agent/client-tools.ts +++ b/services/runner/src/engines/sandbox_agent/client-tools.ts @@ -11,7 +11,7 @@ * `tools/call` handler (`tools/tool-mcp-http.ts`). * * Both consume the `ClientToolRelay` built here, so the pause decision (the responder's verdict - * ladder), the single `client_tool` payload shape, the pause-latch bookkeeping, and the + * ladder), the single `client_tool` payload shape, the pause bookkeeping, and the * ACP-tool-call correlation live in ONE place instead of being re-derived per path. */ import type { AgentEvent, RenderHint } from "../../protocol.ts"; @@ -200,11 +200,6 @@ export function emitClientToolInteraction( }); } -/** The one-pause-per-turn latch surface the seam needs (see `PendingApprovalLatch`). */ -interface LatchLike { - tryAcquire(): boolean; -} - /** The pause-controller surface the seam needs (see `PendingApprovalPauseController`). */ interface PauseLike { markPausedToolCall(toolCallId: string): void; @@ -214,12 +209,14 @@ interface PauseLike { export interface BuildClientToolRelayInput { responder: Responder; run: EmitRun; - /** The approval-dialog latch. Client tools do NOT gate on it — each pending client tool parks - * its own widget, and only the approval path (`acp-interactions.ts`) enforces one dialog per - * turn. Kept in the shared input so the wiring stays symmetric; ignored here. */ - latch?: LatchLike; /** The turn-ender: `pause()` cancels the prompt; `markPausedToolCall` suppresses late frames. */ pause: PauseLike; + /** + * Flag the turn non-parkable when a browser-fulfilled client tool pauses: it cannot be answered + * on the live session across a turn boundary, so a turn that mixes it with an approval gate must + * stay on the cold path. Fires once per pending client tool (idempotent counter on the caller). + */ + onNonParkablePause?: () => void; /** Seeds the durable interactions plane for the pending call (fire-and-forget). */ recordPendingInteraction: ( token: string, @@ -247,6 +244,7 @@ export function buildClientToolRelay({ pause, recordPendingInteraction, toolCallIndex, + onNonParkablePause, log = () => {}, }: BuildClientToolRelayInput): ClientToolRelay { return { @@ -281,12 +279,15 @@ export function buildClientToolRelay({ const correlatedId = toolCallIndex?.lookup(request.toolName, request.input) ?? request.toolCallId; - // Every pending client tool parks its OWN widget. Unlike an approval DIALOG (one at a time, - // gated by the latch), browser-fulfilled client tools are independent surfaces — an agent may - // request several connections in one turn, and each must render. The turn still ends exactly - // once: `pause.pause()` (via `onPause`) is idempotent, so N emits pause the turn once, and - // `markPausedToolCall` keeps each parked call from being force-settled as a deferred sibling. + // Every pending client tool parks its OWN widget: browser-fulfilled client tools are + // independent surfaces — an agent may request several connections in one turn, and each must + // render. Approval gates now behave the same way (no per-turn cap). The turn still ends + // exactly once: `pause.pause()` (via `onPause`) is idempotent, so N emits pause the turn + // once, and `markPausedToolCall` keeps each parked call from being force-settled as a sibling. pause.markPausedToolCall(correlatedId); + // A client-tool pause keeps the whole turn on the cold path: the warm resume cannot answer + // a browser-fulfilled call, so a turn that also holds an approval gate must not park. + onNonParkablePause?.(); emitClientToolInteraction(run, { id: request.id, toolCallId: correlatedId, diff --git a/services/runner/src/engines/sandbox_agent/environment-setup.ts b/services/runner/src/engines/sandbox_agent/environment-setup.ts index e27d25fbb7..3e9d8ecfb7 100644 --- a/services/runner/src/engines/sandbox_agent/environment-setup.ts +++ b/services/runner/src/engines/sandbox_agent/environment-setup.ts @@ -347,8 +347,10 @@ export async function prepareEnvironmentSetup( closeToolMcp: undefined, currentTurn: undefined, lastTurnToolCallIds: [], + parkedApprovals: new Map(), parkedApproval: undefined, approvalGateCount: 0, + nonParkablePauseCount: 0, destroyed: false, destroy: async () => {}, clearTurn: () => {}, diff --git a/services/runner/src/engines/sandbox_agent/environment.ts b/services/runner/src/engines/sandbox_agent/environment.ts index 7d38fcb5c0..e77fe382bb 100644 --- a/services/runner/src/engines/sandbox_agent/environment.ts +++ b/services/runner/src/engines/sandbox_agent/environment.ts @@ -99,11 +99,7 @@ import { routeSessionEventToActiveTurn, } from "./session-events.ts"; import { buildSandboxProvider } from "./provider.ts"; -import { - clearSandboxPointer, - readStoredSandboxPointer, - writeSandboxPointer, -} from "./sandbox-reconnect.ts"; +import { readStoredSandboxPointer } from "./sandbox-reconnect.ts"; import type { AcquireEnvironmentResult, SandboxAgentDeps, @@ -117,7 +113,6 @@ import { import { hydrateHarnessSessionFromDurable } from "./session-continuity-durable.ts"; import { eligibleAgentSessionId, - nextTurnIndex, sessionContinuityStore, } from "./session-continuity.ts"; import { projectScopeFor } from "./session-identity.ts"; @@ -642,31 +637,13 @@ export async function acquireEnvironment( logger( `reconnect failed sandbox=${storedSandboxPointer.sandboxId}, creating fresh: ${conciseError(err, plan.harness)}`, ); - if ( - err instanceof DaytonaReconnectTerminalError && - sessionForMount && - runCred - ) { - // The post-hydrate write later in acquire is authoritative. This clear only prevents - // repeated doomed reconnects if acquire fails before reaching that write. Hydrate - // first: after a runner restart the in-memory store is behind the durable - // latest_turn_index, and an unhydrated guard token would be rejected as stale. - await ( - deps.hydrateHarnessSessionFromDurable ?? - hydrateHarnessSessionFromDurable - )( - sessionForMount, - plan.harness, - deps.sessionContinuityStore ?? sessionContinuityStore, - { authorization: runCred, log: logger }, - ); - await (deps.clearSandboxPointer ?? clearSandboxPointer)( - sessionForMount, - nextTurnIndex( - sessionForMount, - deps.sessionContinuityStore ?? sessionContinuityStore, - ), - { authorization: runCred, log: logger }, + // No explicit pointer clear needed: turns are append-only, so the fresh sandbox this + // turn creates below gets its own turn row at completion, and that row's higher + // turn_index naturally supersedes the dead one on the next `latest_turn` read — the + // staleness guard the old states model needed dissolves with the ordering. + if (err instanceof DaytonaReconnectTerminalError) { + logger( + `terminal Daytona state '${err.state}' for sandbox=${storedSandboxPointer.sandboxId}, not retrying reconnect`, ); } } finally { @@ -979,29 +956,9 @@ export async function acquireEnvironment( const localSessionId = continuitySessionKey ? `${continuitySessionKey}:${plan.harness}` : undefined; - // The index THIS turn will occupy once it completes: recorded post-turn against the SAME - // index read here, so a turn that authors turn N leaves the store agreeing with itself. - environment.continuityTurnIndex = continuitySessionKey - ? nextTurnIndex(continuitySessionKey, continuityStore) - : undefined; - // Daytona only: a local run must not overwrite a conversation's remote pointer (switching - // sandboxes mid-conversation would strand the parked Daytona instance). - if (plan.isDaytona && sessionForMount && runCred) { - const liveSandboxId = environment.sandbox?.sandboxId ?? plan.sandboxId; - const pointerWriteOutcome = await ( - deps.writeSandboxPointer ?? writeSandboxPointer - )( - sessionForMount, - { - sandboxId: liveSandboxId, - turnIndex: environment.continuityTurnIndex ?? 0, - }, - { authorization: runCred, log: logger }, - ); - logger( - `sandbox pointer write ${pointerWriteOutcome} session=${sessionForMount} sandbox=${liveSandboxId}`, - ); - } + // The live sandbox id rides forward as a field on the turn-append row written at turn end + // (see `appendSessionTurn` call in `runTurn`), not a separate pre-turn pointer PUT: the + // turns table is append-only, so there is nothing to overwrite mid-conversation. let loadedFromContinuity = false; if (priorAgentSessionId && localSessionId) { await persist.updateSession({ diff --git a/services/runner/src/engines/sandbox_agent/pause.ts b/services/runner/src/engines/sandbox_agent/pause.ts index 15bfb4d22b..fe7b2dc23e 100644 --- a/services/runner/src/engines/sandbox_agent/pause.ts +++ b/services/runner/src/engines/sandbox_agent/pause.ts @@ -3,15 +3,21 @@ * * F-040: an unanswered approval must end the turn, destroy the session, and never reply to the * harness gate. Historical docs call this "park"; this module uses pause/pendingApproval names. - * The destroy callback also settles every announced-but-unresolved sibling tool call with a - * deterministic `tool_result` before teardown, so the client never holds an orphaned part. + * Terminalization classifies every announced-but-unresolved sibling after managed cancellation + * drains, so the client never holds an orphaned part or an invented execution result. */ export const PAUSED = Symbol("paused"); +const EVENT_DRAIN_QUIET_TICKS = 2; +const EVENT_DRAIN_MAX_TICKS = 6; + export class PendingApprovalPauseController { private pendingApproval = false; private readonly pausedToolCallIds = new Set(); + private readonly allowedExecutionToolCallIds = new Set(); + private readonly answeredDenyToolCallIds = new Set(); private resolvePause: (() => void) | undefined; + private gateClassificationVersion = 0; private eventDrain: Promise = Promise.resolve(); readonly signal: Promise; @@ -35,12 +41,27 @@ export class PendingApprovalPauseController { } this.eventDrain = Promise.resolve(destroyResult) .catch(() => {}) - .then(() => new Promise((resolve) => setImmediate(resolve))); + .then(() => this.waitForGateClassificationQuietPeriod()); // The turn races this immediate signal; terminalization separately awaits eventDrain so the // permission callback never holds the human pause open while queued ACP updates still settle. this.resolvePause?.(); } + private async waitForGateClassificationQuietPeriod(): Promise { + let observedVersion = this.gateClassificationVersion; + let quietTicks = 0; + for (let tick = 0; tick < EVENT_DRAIN_MAX_TICKS; tick += 1) { + await new Promise((resolve) => setImmediate(resolve)); + if (observedVersion !== this.gateClassificationVersion) { + observedVersion = this.gateClassificationVersion; + quietTicks = 0; + continue; + } + quietTicks += 1; + if (quietTicks >= EVENT_DRAIN_QUIET_TICKS) return; + } + } + /** Wait until managed cancellation and already-queued ACP updates have drained. */ waitForEventDrain(): Promise { return this.eventDrain; @@ -53,13 +74,42 @@ export class PendingApprovalPauseController { */ markPausedToolCall(toolCallId: string): void { if (!toolCallId) return; + const added = !this.pausedToolCallIds.has(toolCallId); this.pausedToolCallIds.add(toolCallId); + // A late gate must extend terminalization long enough for its paused classification to land. + if (this.pendingApproval && added) this.gateClassificationVersion += 1; } isPausedToolCall(toolCallId: string | undefined): boolean { return toolCallId !== undefined && this.pausedToolCallIds.has(toolCallId); } + /** Record that this turn answered allow for the call, so pause cleanup preserves its result. */ + markAllowedExecution(toolCallId: string): void { + if (!toolCallId) return; + this.allowedExecutionToolCallIds.add(toolCallId); + } + + /** Record an answered deny so its authoritative failed frame survives a sibling pause. */ + markAnsweredDeny(toolCallId: string): void { + if (!toolCallId) return; + this.answeredDenyToolCallIds.add(toolCallId); + } + + isAnsweredDeny(toolCallId: string | undefined): boolean { + return ( + toolCallId !== undefined && + this.answeredDenyToolCallIds.has(toolCallId) + ); + } + + isAllowedExecution(toolCallId: string | undefined): boolean { + return ( + toolCallId !== undefined && + this.allowedExecutionToolCallIds.has(toolCallId) + ); + } + get active(): boolean { return this.pendingApproval; } diff --git a/services/runner/src/engines/sandbox_agent/run-turn.ts b/services/runner/src/engines/sandbox_agent/run-turn.ts index 7139732658..9190d90854 100644 --- a/services/runner/src/engines/sandbox_agent/run-turn.ts +++ b/services/runner/src/engines/sandbox_agent/run-turn.ts @@ -1,5 +1,5 @@ import { - PendingApprovalLatch, + effectivePermission, permissionsFromRequest, } from "../../permission-plan.ts"; import { @@ -30,10 +30,14 @@ import { type RelayExecutionGuard, } from "../../tools/relay.ts"; import { + APPROVED_EXECUTION_RESULT_UNKNOWN, createSandboxAgentOtel, TOOL_NOT_EXECUTED_PAUSED, } from "../../tracing/otel.ts"; -import { attachPermissionResponder } from "./acp-interactions.ts"; +import { + attachPermissionResponder, + buildGateDescriptor, +} from "./acp-interactions.ts"; import { buildClientToolRelay, relayWritesPausedAnswer, @@ -55,6 +59,7 @@ import { sendLastMessageOnly, type CurrentTurn, type ParkedApproval, + type ParkedApprovedExecution, type RunTurnOptions, type SessionEnvironment, } from "./runtime-contracts.ts"; @@ -64,15 +69,18 @@ import { shouldSuppressPausedToolCallUpdate, } from "./runtime-policy.ts"; import { - syncHarnessSessionDurable, + appendSessionTurn, } from "./session-continuity-durable.ts"; -import { sessionContinuityStore } from "./session-continuity.ts"; +import { + nextTurnIndex, + sessionContinuityStore, +} from "./session-continuity.ts"; import { priorMessages } from "./transcript.ts"; import { resolveRunUsage } from "./usage.ts"; /** * Run one turn against an acquired environment: start a fresh otel run, wire this turn's pause - * controller / latch / decisions / responder into `env.currentTurn`, restart the tool relay, + * controller / decisions / responder into `env.currentTurn`, restart the tool relay, * send the prompt, resolve usage, and finish + flush the trace. It does NOT tear down the * environment (the caller owns `env.destroy`). On a continuation the prompt is only the new user * text (`buildTurnText` does not run); on a cold turn it is `plan.turnText`, exactly as before. @@ -86,14 +94,28 @@ export async function runTurn( ): Promise { const { plan, logger, deps } = env; const sessionId = env.sessionId; + const continuityStore = deps.sessionContinuityStore ?? sessionContinuityStore; + const turnStartedAt = new Date().toISOString(); + // `turn_index` is a true conversation-turn counter, not an acquire counter: it advances once per completed turn across every environment serving the session. + // The shared store advances only on `record()` (paused turns record nothing), so park-and-resume consumes one index; compute it at turn start because a warm environment serves many turns. + env.continuityTurnIndex = sessionId + ? nextTurnIndex(sessionId, continuityStore) + : undefined; // Reset the per-turn tool-call id record (the park folds the completed turn's ids into the // expected next-history fingerprint). env.lastTurnToolCallIds = []; - // Reset the per-turn approval-park bookkeeping. A fresh turn starts with no parked gate; this - // turn re-records it only if it pauses on a Claude ACP permission gate. (The dispatch has - // already captured any prior park into `opts.resume` before calling us.) + // Reset the per-turn approval-park bookkeeping. A fresh turn starts with no parked gates; this + // turn re-records them only if it pauses on ACP permission gates. (The dispatch has already + // captured any prior park into `opts.resume` before calling us.) + const carriedApprovedExecutions = opts.resume + ? [...(env.parkedApprovedExecutions?.values() ?? [])] + : []; + const parkedApprovedExecutions = new Map(); + env.parkedApprovedExecutions = parkedApprovedExecutions; + env.parkedApprovals.clear(); env.parkedApproval = undefined; env.approvalGateCount = 0; + env.nonParkablePauseCount = 0; // Hoisted so the catch can flush a partial trace (mirroring the pre-split `otel?` handling — // a createOtel throw must still return `{ ok: false }`, not propagate raw) and the finally can // stop this turn's relay on EVERY exit path (a cleared sink must never orphan it). @@ -106,8 +128,9 @@ export async function runTurn( // caller's teardown (`runSandboxAgent`'s `finally`, or the keep-alive dispatch's evict-on-failure) // reclaims the sandbox exactly as any other error does. Disposed in the `finally` on every path. // A human pause retires the deadlines (`notePaused`): a HITL wait is legitimate, not a wedge. + const resolvedRunLimits = (deps.resolveRunLimits ?? resolveRunLimits)(logger); const runLimits = (deps.createRunLimits ?? createRunLimits)( - (deps.resolveRunLimits ?? resolveRunLimits)(logger), + resolvedRunLimits, { log: logger }, ); let runLimitTrip: (() => void) | undefined; @@ -161,34 +184,120 @@ export async function runTurn( ], }); - const pause = new PendingApprovalPauseController(() => { - // The sibling settle runs UNCONDITIONALLY, park mode or not: latch-loser tool calls - // announced before the winning gate can never execute this turn, and skipping the settle - // here would leave them as orphaned open parts whenever the dispatch later refuses the park - // (multi-gate, pool full) — `env.destroy()` does not re-run it. The exclusion keeps the - // gated (paused) call itself open, so the live resume is untouched. - run.settleOpenToolCalls( - (id) => pause.isPausedToolCall(id), - TOOL_NOT_EXECUTED_PAUSED, + const sessionTurnClient = deps.appendSessionTurn ?? appendSessionTurn; + const syncCred = runCredential(request); + const turnLedgerContext = + sessionId && + env.continuityTurnIndex !== undefined && + syncCred && + request.streamId + ? { + sessionId, + turnIndex: env.continuityTurnIndex, + authorization: syncCred, + streamId: request.streamId, + } + : undefined; + if (turnLedgerContext) { + const workflowRefs = buildWorkflowReferences( + request.runContext?.workflow, ); - // Park mode: a parkable permission gate (Claude ACP or Pi ACP) recorded - // `env.parkedApproval` BEFORE firing this pause (the onUserApprovalGate hook runs before - // the single-pause latch). Keep the live session — the gated tool runs on the resume — so - // skip ONLY the mcpAbort and the destroySession. The teardown is not lost: the dispatch - // either parks the session or, if it decides not to (multi-gate, pool full), calls - // `env.destroy()` which runs them. A non-parkable pause (keep-alive off, client tool) - // never records `parkedApproval`, so it still tears down here exactly as today. - if (opts.approvalParkMode && env.parkedApproval) return; + // Row existence proves only that a turn started. Native continuation is trustworthy only + // after `end_time` is set. + await sessionTurnClient( + turnLedgerContext.sessionId, + plan.harness, + turnLedgerContext.turnIndex, + { + streamId: turnLedgerContext.streamId, + turnId: request.turnId, + agentSessionId: env.session?.agentSessionId, + sandboxId: env.sandbox?.sandboxId, + references: workflowRefs ? Object.values(workflowRefs) : undefined, + traceId: + run.traceId() ?? request.runContext?.trace?.trace_id, + spanId: request.runContext?.trace?.span_id, + startTime: turnStartedAt, + }, + { authorization: turnLedgerContext.authorization, log: logger }, + ).catch(() => {}); + } + + const pause = new PendingApprovalPauseController(() => { + // Do NOT force-settle open tool calls here, at first pause. With concurrent approvals a + // second gated call may still be in flight (its permission request lands a tick after the + // first gate pauses the turn), and settling it here would orphan a call that is about to + // emit its own approval card. The orphan settle is deferred to the post-drain sweep below + // (which runs on every paused turn, after `waitForEventDrain` lets every pending gate mark + // its call paused) plus the in-band re-sweep in `handleUpdate` for a sibling announced after + // the pause. Both exclude paused gates and allowed executions, so each keeps its own + // terminal outcome while only a genuine orphan settles. + // Park mode: at least one parkable permission gate (Claude ACP or Pi ACP) recorded into + // `env.parkedApprovals` BEFORE firing this pause (the onUserApprovalGate hook runs as each + // gate resolves). Keep the live session — the gated tools run on the resume — so skip ONLY + // the mcpAbort and the destroySession. The teardown is not lost: the dispatch either parks + // the session or, if it decides not to (mixed non-parkable set, pool full), calls + // `env.destroy()` which runs them. A pause with no parkable gate (keep-alive off, client + // tool only) records nothing, so it still tears down here exactly as today. + if (opts.approvalParkMode && env.parkedApprovals.size > 0) return; // Abort any in-flight loopback `tools/call` (a paused Claude client tool) BEFORE the // session teardown, so its handler cannot write a result after the turn ends. env.mcpAbort.abort(); env.sessionDestroyRequested = true; return env.sandbox.destroySession?.(env.session.id); }); + if (opts.resume?.carriedForward.length) { + for (const gate of opts.resume.carriedForward) { + env.parkedApprovals.set(gate.toolCallId, gate); + env.parkedApproval ??= gate; + pause.markPausedToolCall(gate.toolCallId); + } + env.approvalGateCount = env.parkedApprovals.size; + } // A human pause resolves this signal exactly once, the moment the turn parks for input — the one // place every pause path converges, so the one place to retire the run-limits deadlines for good. void pause.signal.then(() => runLimits.notePaused()); + const openToolCallIds = (): string[] => run.openToolCallIds?.() ?? []; + const approvedExecutionSeeds = new Map( + carriedApprovedExecutions.map((seed) => [seed.toolCallId, seed]), + ); + const bufferedPausedCompletedFrames = new Map(); + const toolCallClosureWaiters = new Map void>>(); + const notifyToolCallClosed = (toolCallId: string): void => { + if (openToolCallIds().includes(toolCallId)) return; + const waiters = toolCallClosureWaiters.get(toolCallId); + if (!waiters) return; + toolCallClosureWaiters.delete(toolCallId); + for (const waiter of waiters) waiter(); + }; + const waitForToolCallClosure = ( + toolCallId: string, + timeoutMs: number, + ): Promise => { + if (!openToolCallIds().includes(toolCallId)) { + return Promise.resolve(true); + } + return new Promise((resolve) => { + let timeout: NodeJS.Timeout | undefined; + let finished = false; + const finish = (closed: boolean): void => { + if (finished) return; + finished = true; + if (timeout) clearTimeout(timeout); + const waiters = toolCallClosureWaiters.get(toolCallId); + waiters?.delete(onClosed); + if (waiters?.size === 0) toolCallClosureWaiters.delete(toolCallId); + resolve(closed); + }; + const onClosed = (): void => finish(true); + const waiters = toolCallClosureWaiters.get(toolCallId) ?? new Set(); + waiters.add(onClosed); + toolCallClosureWaiters.set(toolCallId, waiters); + timeout = setTimeout(() => finish(false), timeoutMs); + }); + }; + // Publish this turn's sink so the session-lifetime listeners route into it. handleUpdate // reproduces the old per-event routing (suppress paused frames, handleUpdate, pause re-sweep). const turn: CurrentTurn = { @@ -228,12 +337,59 @@ export async function runTurn( ) { env.lastTurnToolCallIds.push(frame.toolCallId); } + if ( + frame?.sessionUpdate === "tool_call" && + typeof frame.toolCallId === "string" && + frame.toolCallId + ) { + const announced = update as { + name?: unknown; + title?: unknown; + kind?: unknown; + rawInput?: unknown; + input?: unknown; + }; + const existing = approvedExecutionSeeds.get(frame.toolCallId); + const toolName = [announced.name, announced.title, announced.kind] + .find( + (value): value is string => + typeof value === "string" && value.length > 0, + ) ?? existing?.toolName; + approvedExecutionSeeds.set(frame.toolCallId, { + toolCallId: frame.toolCallId, + toolName, + args: announced.rawInput ?? announced.input ?? existing?.args, + }); + } + const toolCallId = + typeof rawFrame.toolCallId === "string" + ? rawFrame.toolCallId + : undefined; + if ( + pause.active && + (rawFrame.sessionUpdate === "tool_call" || + rawFrame.sessionUpdate === "tool_call_update") && + rawFrame.status === "completed" && + toolCallId && + !pause.isPausedToolCall(toolCallId) && + !pause.isAllowedExecution(toolCallId) + ) { + bufferedPausedCompletedFrames.set(toolCallId, update); + return; + } run.handleUpdate(update); - // A sibling announced AFTER the pause won the latch can never execute; settle it - // immediately so the client never holds an orphaned part (idempotent re-sweep). + if ( + toolCallId && + (rawFrame.status === "completed" || rawFrame.status === "failed") + ) { + notifyToolCallClosed(toolCallId); + } + // A sibling announced after the pause with neither a gate nor an allow cannot execute; + // the idempotent re-sweep closes it so the client never holds an orphaned part. if (pause.active) { run.settleOpenToolCalls( - (id) => pause.isPausedToolCall(id), + (id) => + pause.isPausedToolCall(id) || pause.isAllowedExecution(id), TOOL_NOT_EXECUTED_PAUSED, ); } @@ -256,7 +412,18 @@ export async function runTurn( extractClientToolOutputs(request), ); const executionGrants = new ApprovedExecutionGrants(); - const latch = new PendingApprovalLatch(); + const seedApprovedExecution = (seed: ParkedApprovedExecution): void => { + approvedExecutionSeeds.set(seed.toolCallId, seed); + run.handleUpdate({ + sessionUpdate: "tool_call", + toolCallId: seed.toolCallId, + title: seed.toolName, + kind: seed.toolName, + rawInput: seed.args, + }); + pause.markAllowedExecution(seed.toolCallId); + executionGrants.grant(seed.toolName, seed.args); + }; const responder = deps.responderFactory?.(request) ?? new ApprovalResponder(permissionPlan, decisions, logger); @@ -270,7 +437,7 @@ export async function runTurn( const cred = runCredential(request); if (!cred) return; const references = buildWorkflowReferences(request.runContext?.workflow); - if (!references?.workflow_revision) return; + // Every gate leaves a durable inbox/audit row; workflow references are attribution, not a precondition. void createInteraction( sessionId, request.turnId ?? "", @@ -287,20 +454,92 @@ export async function runTurn( // interactions-plane answer already transitioned it to responded, and an in-band answer is // detected at sweep time (`inBandAnswerToken`) and exempted via the sweep's `tokens` — the // row stays pending until this resolve lands it as resolved, never cancelled. - const resolveInteractionToken = (token: string): void => { + const resolveInteractionToken = ( + token: string, + verdict?: { approved: boolean; toolCallId: string }, + ): void => { + if (verdict) { + run.emitEvent({ + type: "interaction_response", + id: token, + kind: "user_approval", + payload: verdict, + }); + } const cred = runCredential(request); if (!cred) return; - if ( - !buildWorkflowReferences(request.runContext?.workflow) - ?.workflow_revision - ) - return; - void resolveInteraction(sessionId, token, () => cred); + void resolveInteraction( + sessionId, + token, + () => cred, + verdict + ? { + verdict: verdict.approved ? "approved" : "denied", + tool_call_id: verdict.toolCallId, + } + : undefined, + ); }; const serverPermissions = serverPermissionsFromRequest(request); // The SAME name->spec index the relay execute loop hands to the relay execution guard, so // the approval card and the guard cannot disagree about a tool's permission/readOnly. const specsByName = toolSpecsByName(plan.toolSpecs); + const settleBufferedPausedCompletions = (): void => { + for (const [toolCallId, update] of [ + ...bufferedPausedCompletedFrames.entries(), + ]) { + bufferedPausedCompletedFrames.delete(toolCallId); + if (pause.isPausedToolCall(toolCallId)) continue; + if (pause.isAllowedExecution(toolCallId)) { + run.handleUpdate(update); + notifyToolCallClosed(toolCallId); + continue; + } + const frame = update as { + sessionUpdate?: unknown; + name?: unknown; + title?: unknown; + kind?: unknown; + rawInput?: unknown; + input?: unknown; + }; + const { gate } = buildGateDescriptor( + { + toolCallId, + name: frame.name, + title: frame.title, + kind: frame.kind, + rawInput: frame.rawInput, + input: frame.input, + }, + run, + serverPermissions, + specsByName, + ); + const permission = effectivePermission(gate, permissionPlan); + if (permission === "allow") { + run.handleUpdate(update); + notifyToolCallClosed(toolCallId); + continue; + } + // Execution of an ask-policy call requires an answered allow; both harness gate paths fail + // closed. A completed frame during a pause for an unanswered ask-policy call is therefore + // a cancellation-closure artifact, not evidence of execution. + if ( + frame.sessionUpdate === "tool_call" && + !openToolCallIds().includes(toolCallId) + ) { + run.handleUpdate({ + ...(update as Record), + status: undefined, + }); + } + run.settleOpenToolCalls( + (id) => id !== toolCallId, + TOOL_NOT_EXECUTED_PAUSED, + ); + } + }; // Build the per-turn permission handler WITHOUT attaching to the live session: the // session-lifetime `onPermissionRequest` (in acquireEnvironment) routes into it via // `currentTurn`. A capturing shim reuses attachPermissionResponder unchanged; its @@ -315,11 +554,15 @@ export async function runTurn( }, run, responder, - latch, serverPermissions, log: logger, onPause: () => pause.pause(), onPausedToolCall: (id) => pause.markPausedToolCall(id), + onAllowedExecution: (id) => pause.markAllowedExecution(id), + onAnsweredDeny: (id) => pause.markAnsweredDeny(id), + onNonParkablePause: () => { + env.nonParkablePauseCount += 1; + }, onCreateInteraction: recordPendingInteraction, onResolveInteraction: resolveInteractionToken, toolSpecsByName: specsByName, @@ -346,27 +589,28 @@ export async function runTurn( // only a dialog-approved (or policy-allowed) call ever executes from the relay dir. onPiGateAllowed: (info) => executionGrants.grant(info.toolName, info.args), - // Record the parkable permission gate (only in keep-alive park mode) so the dispatch can - // resume it live. Fires per pending gate (before the latch) so a parallel gate is counted; - // the single-gate resume records only the FIRST gate's answer target. `info.gateType` names - // the plane (Claude ACP vs Pi ACP) so the resume answers on the right one. + // Record EVERY parkable permission gate (only in keep-alive park mode) so the dispatch can + // resume each one live. Fires per pending gate, so parallel gated tool calls in one turn + // all park, each keyed by its own tool-call id. `info.gateType` names the plane (Claude ACP + // vs Pi ACP) so the resume answers on the right one. `approvalGateCount` counts every gate; + // a gate that lacked a resumable id is counted but not recorded, so the dispatch can tell + // "every gate is resumable" (count === map size) from "a gate cannot be resumed live". onUserApprovalGate: opts.approvalParkMode ? (info) => { env.approvalGateCount += 1; - if ( - env.approvalGateCount === 1 && - info.permissionId && - info.toolCallId - ) { - env.parkedApproval = { - gateType: info.gateType, - permissionId: info.permissionId, - toolCallId: info.toolCallId, - toolName: info.toolName, - args: info.args, - interactionToken: info.interactionToken, - }; - } + if (!info.permissionId || !info.toolCallId) return; + const record: ParkedApproval = { + gateType: info.gateType, + permissionId: info.permissionId, + toolCallId: info.toolCallId, + toolName: info.toolName, + args: info.args, + interactionToken: info.interactionToken, + }; + env.parkedApprovals.set(info.toolCallId, record); + // The first recorded gate is the per-turn representative for logging and the + // per-turn-uniform validation reads (gate type, history, credentials). + env.parkedApproval ??= record; } : undefined, }); @@ -376,10 +620,12 @@ export async function runTurn( env.clientToolRelayRef.current = buildClientToolRelay({ responder, run, - latch, pause, recordPendingInteraction, toolCallIndex: plan.isPi ? undefined : env.toolCallIndex, + onNonParkablePause: () => { + env.nonParkablePauseCount += 1; + }, log: logger, }); @@ -435,44 +681,62 @@ export async function runTurn( // resolves, and the pause signal ends the turn. let promptPromise: Promise; if (opts.resume) { - // The new (resume) turn owns streaming + tracing; the environment is already wired to route - // continued events into this turn's sink (env.currentTurn was set above). Seed this run's - // trace with the parked tool call so the completing `tool_call_update` closes it and the FE - // approval part flips to output-available even if the adapter re-announces nothing. Then - // answer the gate on the live session — the original prompt continues from here. - run.handleUpdate({ - sessionUpdate: "tool_call", - toolCallId: opts.resume.toolCallId, - title: opts.resume.toolName, - kind: opts.resume.toolName, - rawInput: opts.resume.args, - }); - promptPromise = Promise.resolve(opts.resume.promptPromise); + // The resume turn owns continued events; each decision answers one parked gate by id. + // Carried gates keep the shared original prompt pending until a later answer. + const decisions = opts.resume.decisions; + promptPromise = Promise.resolve(decisions[0]?.promptPromise); promptPromise.catch(() => {}); - // A parked Pi dialog gate resumes on a FRESH turn whose relay and grant ledger are new; - // grant the approved call here so the extension's execute record (written right after the - // confirm resolves) passes the relay guard. Claude resumes grant too — harmlessly, no - // guard consults it. - if (opts.resume.reply === "once") { - executionGrants.grant(opts.resume.toolName, opts.resume.args); + for (const seed of carriedApprovedExecutions) { + seedApprovedExecution(seed); } - // A live-resume deny closes the seeded call as a failed tool call; flag it so the egress - // projects `tool-output-denied` (a decline), mirroring the cold decision-map deny path. - if (opts.resume.reply === "reject") { - run.markToolCallDenied(opts.resume.toolCallId); + for (const decision of decisions) { + // Seed this run's trace with the parked tool call so the completing `tool_call_update` + // closes it and the FE approval part flips to output-available even if the adapter + // re-announces nothing. + run.handleUpdate({ + sessionUpdate: "tool_call", + toolCallId: decision.toolCallId, + title: decision.toolName, + kind: decision.toolName, + rawInput: decision.args, + }); + // A parked Pi dialog gate resumes on a FRESH turn whose relay and grant ledger are new; + // grant the approved call here so the extension's execute record (written right after the + // confirm resolves) passes the relay guard. Claude resumes grant too — harmlessly, no + // guard consults it. + if (decision.reply === "once") { + approvedExecutionSeeds.set(decision.toolCallId, { + toolCallId: decision.toolCallId, + toolName: decision.toolName, + args: decision.args, + }); + pause.markAllowedExecution(decision.toolCallId); + executionGrants.grant(decision.toolName, decision.args); + } + // A live-resume deny closes the seeded call as a failed tool call; flag it so the egress + // projects `tool-output-denied` (a decline), mirroring the cold decision-map deny path. + if (decision.reply === "reject") { + run.markToolCallDenied(decision.toolCallId); + pause.markAnsweredDeny(decision.toolCallId); + } + // Answer this gate on the live session. Each parked gate holds its OWN pending + // `respondPermission` on the harness, so answering them one by one settles each + // independently — an approve and a deny in the same turn each land on the right call. + await env.session.respondPermission(decision.permissionId, decision.reply); + // The gate is answered: resolve its durable interaction row (the parked pending row the + // cold path would otherwise resolve via its decision map). Only carried-forward ids were + // re-marked paused, so answered calls stream their terminal frames normally. + resolveInteractionToken(decision.interactionToken, { + approved: decision.reply === "once", + toolCallId: decision.toolCallId, + }); + logger( + `[keepalive] resume answered gate reply=${decision.reply} tool=${decision.toolName ?? "?"}`, + ); } - await env.session.respondPermission( - opts.resume.permissionId, - opts.resume.reply, - ); - // The gate is answered: resolve the durable interaction row (the parked pending row the cold - // path would otherwise resolve via its decision map). The fresh per-turn pause controller - // starts with an EMPTY pausedToolCallIds set, so the resumed call's `tool_call_update` frames - // are no longer suppressed and stream through — the "clear pausedToolCallIds on resume" step. - resolveInteractionToken(opts.resume.interactionToken); - logger( - `[keepalive] resume answered gate reply=${opts.resume.reply} tool=${opts.resume.toolName ?? "?"}`, - ); + // The harness still holds carried gates inside the original prompt, so re-arm the pause after + // this answer batch and let the normal park path refresh their approval TTL. + if (opts.resume.carriedForward.length > 0) pause.pause(); } else { promptPromise = Promise.resolve( env.session.prompt([{ type: "text", text: turnText }]), @@ -491,24 +755,72 @@ export async function runTurn( } const stopReason = raced === PAUSED || pause.active ? "paused" : (raced as any)?.stopReason; - // Pause notification is immediate, but terminalization must wait for managed cancellation - // and already-queued ACP updates. Re-sweep after the drain so a sibling announced during - // cancellation receives exactly one deterministic terminal result before `done`. + // Terminalization drains queued gates, classifies pause-time completions, and gives allowed + // executions their original per-call bound before the orphan sweep closes the turn. if (stopReason === "paused") { await pause.waitForEventDrain(); + settleBufferedPausedCompletions(); + const openAllowedExecutions = openToolCallIds() + .filter((id) => pause.isAllowedExecution(id)); + const piBatchBlockedByApproval = Boolean( + opts.resume && + plan.isPi && + opts.approvalParkMode && + env.parkedApprovals.size > 0, + ); + if (piBatchBlockedByApproval) { + // Pi prepares every call in a parallel batch before it executes any of them. While a + // sibling gate is pending, closure is impossible, so carry the approved spans and park. + for (const toolCallId of openAllowedExecutions) { + const seed = approvedExecutionSeeds.get(toolCallId) ?? { + toolCallId, + toolName: undefined, + args: undefined, + }; + parkedApprovedExecutions.set(toolCallId, seed); + run.settleOpenToolCalls( + (id) => id !== toolCallId, + APPROVED_EXECUTION_RESULT_UNKNOWN, + ); + } + } else { + await Promise.all( + openAllowedExecutions.map(async (toolCallId) => { + const closed = await waitForToolCallClosure( + toolCallId, + resolvedRunLimits.toolCallMs, + ); + if (closed) return; + run.settleOpenToolCalls( + (id) => id !== toolCallId, + APPROVED_EXECUTION_RESULT_UNKNOWN, + ); + }), + ); + } + settleBufferedPausedCompletions(); run.settleOpenToolCalls( - (id) => pause.isPausedToolCall(id), + (id) => + pause.isPausedToolCall(id) || pause.isAllowedExecution(id), TOOL_NOT_EXECUTED_PAUSED, ); + const unexpectedOpenToolCallIds = openToolCallIds() + .filter((id) => !pause.isPausedToolCall(id)); + if (unexpectedOpenToolCallIds.length > 0) { + logger( + "[HITL] paused-turn transcript invariant left non-gated calls open: " + + unexpectedOpenToolCallIds.join(","), + ); + } } const result = raced === PAUSED ? undefined : raced; - // A parkable pause this turn: hand the still-pending prompt promise to the parked record so a - // later resume can await the same continuation. (Set after the race so `promptPromise` exists. - // The read is asserted because the onUserApprovalGate callback set the field via an async - // mutation TS's flow analysis cannot see, so it would otherwise narrow the reset to `never`.) - const parkedThisTurn = env.parkedApproval as ParkedApproval | undefined; - if (opts.approvalParkMode && pause.active && parkedThisTurn) { - parkedThisTurn.promptPromise = promptPromise; + // A parkable pause this turn: hand the still-pending prompt promise to EVERY parked record so a + // later resume can await the same continuation (there is one prompt per turn, so every gate + // shares it). Set after the race so `promptPromise` exists. + if (opts.approvalParkMode && pause.active && env.parkedApprovals.size > 0) { + for (const record of env.parkedApprovals.values()) { + record.promptPromise = promptPromise; + } } await turn.toolRelay?.stop(); logger(`prompt stopReason=${stopReason}`); @@ -544,6 +856,7 @@ export async function runTurn( const output = run.finish(); await run.flush(); + const turnEndedAt = new Date().toISOString(); if (swallowedError) { // A failed turn may have left a partial turn in the native transcript: the prior record @@ -552,32 +865,35 @@ export async function runTurn( return { ok: false, error: swallowedError }; } - // Capture this harness's native session id for the next turn's setup. Only on a turn that - // actually completed (not paused mid-turn — a park has not finished authoring the turn, so - // it must not be marked authoritative) and only when the harness surfaced one. + // A pause has not finished authoring the turn, so only a completed execution can advance the + // in-memory resume pointer or complete the durable ledger row. if ( stopReason !== "paused" && env.continuityTurnIndex !== undefined && - sessionId && - env.session?.agentSessionId + sessionId ) { - (deps.sessionContinuityStore ?? sessionContinuityStore).record( - sessionId, - plan.harness, - env.session.agentSessionId, - env.continuityTurnIndex, - ); - // Mirror the record durably so it survives a runner restart; fire-and-forget. - const syncCred = runCredential(request); - if (syncCred) { - void (deps.syncHarnessSessionDurable ?? syncHarnessSessionDurable)( + const agentSessionId = env.session?.agentSessionId; + if (agentSessionId) { + (deps.sessionContinuityStore ?? sessionContinuityStore).record( sessionId, plan.harness, - env.session.agentSessionId, + agentSessionId, env.continuityTurnIndex, - { authorization: syncCred, log: logger }, ); } + + const completeTurn = sessionTurnClient.complete; + if (turnLedgerContext && completeTurn) { + await completeTurn( + turnLedgerContext.sessionId, + turnLedgerContext.turnIndex, + { + agentSessionId, + endTime: turnEndedAt, + }, + { authorization: turnLedgerContext.authorization, log: logger }, + ).catch(() => {}); + } } else if (stopReason === "paused") { // A pause stopped mid-turn, after the harness may have written a partial turn natively. invalidateContinuity(sessionId, plan.harness, deps); diff --git a/services/runner/src/engines/sandbox_agent/runtime-contracts.ts b/services/runner/src/engines/sandbox_agent/runtime-contracts.ts index 01e82d9b1e..d32d446e2e 100644 --- a/services/runner/src/engines/sandbox_agent/runtime-contracts.ts +++ b/services/runner/src/engines/sandbox_agent/runtime-contracts.ts @@ -18,8 +18,8 @@ import { PendingApprovalPauseController } from "./pause.ts"; import { buildSandboxProvider } from "./provider.ts"; import { createRunLimits, resolveRunLimits } from "./run-limits.ts"; import { type BuildRunPlanDeps, type RunPlan } from "./run-plan.ts"; -import { clearSandboxPointer, readStoredSandboxPointer, writeSandboxPointer } from "./sandbox-reconnect.ts"; -import { hydrateHarnessSessionFromDurable, syncHarnessSessionDurable } from "./session-continuity-durable.ts"; +import { readStoredSandboxPointer } from "./sandbox-reconnect.ts"; +import { appendSessionTurn, hydrateHarnessSessionFromDurable } from "./session-continuity-durable.ts"; import { type SessionContinuityStore } from "./session-continuity.ts"; import { type TeardownReason } from "./teardown.ts"; import { uploadToolMcpAssets } from "./tool-mcp-assets.ts"; @@ -57,13 +57,12 @@ export interface SandboxAgentDeps extends BuildRunPlanDeps { createRunLimits?: typeof createRunLimits; /** Session-continuity store override (tests inject their own; default is the process singleton). */ sessionContinuityStore?: SessionContinuityStore; - /** Durable read-back/write-forward of the continuity store (tests inject fakes). */ + /** Durable read-back/append-forward of the continuity store (tests inject fakes). */ hydrateHarnessSessionFromDurable?: typeof hydrateHarnessSessionFromDurable; - syncHarnessSessionDurable?: typeof syncHarnessSessionDurable; - /** Durable read/write of the sandbox pointer, for the remote reconnect ladder. */ + appendSessionTurn?: typeof appendSessionTurn; + /** Durable read of the sandbox pointer (the latest turn's sandbox_id), for the remote + * reconnect ladder. The write side is folded into `appendSessionTurn`. */ readStoredSandboxPointer?: typeof readStoredSandboxPointer; - clearSandboxPointer?: typeof clearSandboxPointer; - writeSandboxPointer?: typeof writeSandboxPointer; /** * Resolve `{replicaId, ownerReplicaId}` for a session-owned local-sandbox run, so * `acquireEnvironment` can fail loudly instead of silently cold-starting on a non-owner @@ -138,6 +137,17 @@ export interface ResumeApprovalInput { promptPromise?: Promise; } +/** + * An approved Pi call whose batched execution is still blocked by a sibling approval. The next + * resume seeds this context into its tracer and execution-grant ledger before Pi can emit the + * batch's real terminal frame. + */ +export interface ParkedApprovedExecution { + toolCallId: string; + toolName: string | undefined; + args: unknown; +} + /** Per-turn options for `runTurn`. Absent (flag off / cold) means today's byte-identical path. */ export interface RunTurnOptions { /** A live continuation: send only the new user text instead of the full cold transcript. */ @@ -157,8 +167,14 @@ export interface RunTurnOptions { * keep-alive turn. */ approvalParkMode?: boolean; - /** A live approval resume: answer the parked gate and stream the continued prompt's events. */ - resume?: ResumeApprovalInput; + /** + * A live approval resume: answer the matching parked gates and carry the untouched gates into + * the next park. All decisions share the one held prompt promise (there is one prompt per turn). + */ + resume?: { + decisions: ResumeApprovalInput[]; + carriedForward: ParkedApproval[]; + }; } /** @@ -223,17 +239,36 @@ export interface SessionEnvironment { */ lastTurnToolCallIds: string[]; /** - * The Claude ACP permission gate the LAST turn paused on, or undefined. Set only for a harness - * ACP permission gate, reset at each turn start; the dispatch reads it after a paused turn to - * decide whether to park in `awaiting_approval` and, on the next request, how to resume. + * Every parkable ACP permission gate the LAST turn paused on, keyed by the gated tool-call id + * (reset at each turn start). This is the source of truth the warm resume iterates: a turn can + * hold more than one gate (parallel gated tool calls), and each is answered by its own + * `permissionId` on the live session. Empty when no parkable gate paused the turn. + */ + parkedApprovals: Map; + /** + * The FIRST parked gate this turn, a convenience for per-turn-uniform reads (logging, the + * gate-type check, the shared history/credential validation). Undefined when the map is empty. + * The multi-answer resume and the all-parkable park check read `parkedApprovals`, not this. */ parkedApproval?: ParkedApproval; /** - * How many Claude ACP permission gates resolved to pendingApproval THIS turn (reset at turn - * start). More than one means parallel gates the single-gate resume cannot answer, so the - * dispatch does not park (tears down cold as today). + * Approved Pi calls settled with the non-retry unknown-result sentinel while a sibling gate was + * parked. Consumed and re-seeded on the next live resume; empty outside that internal carry. + */ + parkedApprovedExecutions?: Map; + /** + * How many ACP permission gates resolved to pendingApproval THIS turn (reset at turn start). + * Equals `parkedApprovals.size` when every gate carried a resumable tool-call id; a larger + * count means a gate lacked an id and cannot be resumed live, so the dispatch stays cold. */ approvalGateCount: number; + /** + * How many NON-parkable pauses happened this turn (a client-tool ACP gate or a browser-fulfilled + * relay/MCP client tool), reset at turn start. Non-zero means the turn mixes an unanswerable + * client-tool pause into the set, so the whole turn stays on the cold path (only cold can + * multiplex a mixed set today). + */ + nonParkablePauseCount: number; destroyed: boolean; /** Complete, idempotent teardown selected from the typed teardown reason. */ destroy: (opts?: { reason?: TeardownReason }) => Promise; diff --git a/services/runner/src/engines/sandbox_agent/runtime-policy.ts b/services/runner/src/engines/sandbox_agent/runtime-policy.ts index 8df0af65be..7d883bc2c4 100644 --- a/services/runner/src/engines/sandbox_agent/runtime-policy.ts +++ b/services/runner/src/engines/sandbox_agent/runtime-policy.ts @@ -39,7 +39,20 @@ export function shouldSuppressPausedToolCallUpdate( if (kind !== "tool_call" && kind !== "tool_call_update") return false; const toolCallId = typeof frame?.toolCallId === "string" ? frame.toolCallId : undefined; - return pause.isPausedToolCall(toolCallId); + // F-024: a paused (gated) tool call's later frames are teardown artifacts and never reach the + // stream. + if (pause.isPausedToolCall(toolCallId)) return true; + // Answered allows and denies both carry authoritative terminal evidence through a sibling pause. + if ( + kind === "tool_call_update" && + pause.active && + frame?.status === "failed" && + !pause.isAllowedExecution(toolCallId) && + !pause.isAnsweredDeny(toolCallId) + ) { + return true; + } + return false; } const CLAUDE_STRICT_DEPLOYMENTS = new Set([ diff --git a/services/runner/src/engines/sandbox_agent/sandbox-reconnect.ts b/services/runner/src/engines/sandbox_agent/sandbox-reconnect.ts index 0154458146..6c2fbdc8db 100644 --- a/services/runner/src/engines/sandbox_agent/sandbox-reconnect.ts +++ b/services/runner/src/engines/sandbox_agent/sandbox-reconnect.ts @@ -1,10 +1,16 @@ /** - * Daytona sandbox reconnect: read the stored sandbox id back so a resumed session restarts the - * parked (stopped/archived) sandbox instead of provisioning a fresh one, and write the live id - * forward after start. Best-effort throughout: a missing/unreadable id, or a failed reconnect, - * degrades to a fresh create (the dead rung), never a hard error. + * Daytona sandbox reconnect: read the latest turn's stored sandbox id so a resumed session + * restarts the parked (stopped/archived) sandbox instead of provisioning a fresh one. + * Best-effort throughout: a missing/unreadable id, or a failed reconnect, degrades to a fresh + * create (the dead rung), never a hard error. + * + * The live id is written forward as a field on the turn-append row (see + * `session-continuity-durable.ts` `appendSessionTurn`), not through a separate pointer PUT: the + * turns table is append-only, so "the latest turn's sandbox_id" IS the current pointer — a late + * lower-index write can never win `ORDER BY turn_index DESC`, dissolving the old atomic + * staleness guard. */ -import { apiBase } from "../../apiBase.ts"; +import { fetchLatestSessionTurn } from "./session-continuity-durable.ts"; export interface SandboxPointerDeps { apiBase?: string; @@ -13,10 +19,6 @@ export interface SandboxPointerDeps { log?: (msg: string) => void; } -function defaultLog(msg: string): void { - process.stderr.write(`[sandbox-reconnect] ${msg}\n`); -} - /** * The stored sandbox instance id for this session, or undefined when none is recorded (first * turn, storage disabled, or unreachable). The id is a provider-scoped handle, so reconnect is @@ -26,104 +28,12 @@ export interface StoredSandboxPointer { sandboxId: string; } -export interface SandboxPointerWrite { - sandboxId: string; - turnIndex: number; -} - -export type SandboxPointerWriteOutcome = "applied" | "rejected" | "failed"; - export async function readStoredSandboxPointer( sessionId: string, deps: SandboxPointerDeps, ): Promise { - const log = deps.log ?? defaultLog; - const doFetch = deps.fetchImpl ?? fetch; - const base = deps.apiBase ?? apiBase(); - try { - const res = await doFetch( - `${base}/sessions/states/?session_id=${encodeURIComponent(sessionId)}`, - { method: "GET", headers: { authorization: deps.authorization } }, - ); - if (!res.ok) return undefined; - const body = (await res.json()) as { - session_state?: { - sandbox_id?: string | null; - } | null; - }; - const id = body.session_state?.sandbox_id; - if (typeof id !== "string" || id.length === 0) return undefined; - return { sandboxId: id }; - } catch (err) { - log( - `read failed session=${sessionId}: ${String(err instanceof Error ? err.message : err).slice(0, 120)}`, - ); - return undefined; - } -} - -/** - * Shared guarded pointer PUT. `write` and `clear` differ only in the sandbox_id they send and - * the returned-row value that counts as applied; the transport, guard token, and error handling - * must stay identical, so they live here once. - */ -async function putSandboxPointer( - sessionId: string, - sandboxId: string | null, - turnIndex: number, - deps: SandboxPointerDeps, - logPrefix: string, -): Promise { - const log = deps.log ?? defaultLog; - const doFetch = deps.fetchImpl ?? fetch; - const base = deps.apiBase ?? apiBase(); - try { - const res = await doFetch( - `${base}/sessions/states/?session_id=${encodeURIComponent(sessionId)}`, - { - method: "PUT", - headers: { "content-type": "application/json", authorization: deps.authorization }, - body: JSON.stringify({ - sandbox_id: sandboxId, - sandbox_turn_index: turnIndex, - }), - }, - ); - if (!res.ok) { - log(`${logPrefix} HTTP ${res.status} session=${sessionId}`); - return "failed"; - } - const body = (await res.json()) as { - session_state?: { sandbox_id?: string | null } | null; - }; - const stored = body.session_state?.sandbox_id; - const applied = sandboxId === null ? stored == null : stored === sandboxId; - return applied ? "applied" : "rejected"; - } catch (err) { - log( - `${logPrefix} failed session=${sessionId}: ${String(err instanceof Error ? err.message : err).slice(0, 120)}`, - ); - return "failed"; - } -} - -/** - * Write the live sandbox instance id forward (best-effort) so the next turn can reconnect it. - * Only Daytona runs record a pointer; a local run leaves the row untouched. - */ -export async function writeSandboxPointer( - sessionId: string, - pointer: SandboxPointerWrite, - deps: SandboxPointerDeps, -): Promise { - return putSandboxPointer(sessionId, pointer.sandboxId, pointer.turnIndex, deps, "write"); -} - -/** Clear a terminal sandbox pointer under the same turn-index guard as pointer writes. */ -export async function clearSandboxPointer( - sessionId: string, - turnIndex: number, - deps: SandboxPointerDeps, -): Promise { - return putSandboxPointer(sessionId, null, turnIndex, deps, "clear"); + const latest = await fetchLatestSessionTurn(sessionId, undefined, deps); + const id = latest?.sandbox_id; + if (typeof id !== "string" || id.length === 0) return undefined; + return { sandboxId: id }; } diff --git a/services/runner/src/engines/sandbox_agent/session-continuity-durable.ts b/services/runner/src/engines/sandbox_agent/session-continuity-durable.ts index cdf417033d..d310347c94 100644 --- a/services/runner/src/engines/sandbox_agent/session-continuity-durable.ts +++ b/services/runner/src/engines/sandbox_agent/session-continuity-durable.ts @@ -1,13 +1,12 @@ /** * Durable mirror of `session-continuity.ts`'s in-memory store, so continuity survives a - * runner restart. Reads the `session_states` row back into the in-memory store at session - * setup, and writes the just-completed turn's record forward after - * `SessionContinuityStore.record()`. Any failure (row absent, API unreachable) is best-effort: - * it degrades to cold text replay, never a hard error. + * runner restart. Reads the session's LATEST `session_turns` row back into the in-memory + * store at session setup, inserts a ledger row when a turn starts, and completes that row when + * the turn finishes. * - * The API row's `data` is replaced wholesale, not per-key-patched server-side, so - * `syncHarnessSessionDurable` does a read-modify-write: GET the current row, splice in this - * harness's fresh entry, PUT the whole `data` object back. + * `appendSessionTurn` is a plain INSERT (`POST /sessions/turns/`) — no read-modify-write, no + * race. It replaces the old `syncHarnessSessionDurable`, which GET-then-PUT the whole + * `session_states.data` blob. */ import { apiBase } from "../../apiBase.ts"; import type { SessionContinuityStore } from "./session-continuity.ts"; @@ -16,19 +15,21 @@ function defaultLog(msg: string): void { process.stderr.write(`[session-continuity/durable] ${msg}\n`); } -interface WireHarnessSessionRecord { +/** A platform entity reference (the API `Reference` shape). */ +export type TurnReference = { id?: string; slug?: string; version?: string }; + +export interface WireSessionTurn { + turn_id?: string; + harness_kind?: string; agent_session_id?: string; + sandbox_id?: string; turn_index?: number; + end_time?: string; } -interface SessionStateDataWire { - latest_agent_session_id?: string; - harness_sessions?: Record; - latest_turn_index?: number; -} - -interface SessionStateWire { - data?: SessionStateDataWire | null; +interface SessionTurnsQueryResponseWire { + count?: number; + turns?: WireSessionTurn[]; } export interface DurableContinuityDeps { @@ -38,138 +39,221 @@ export interface DurableContinuityDeps { log?: (msg: string) => void; } +/** The fields the turn-start write carries, beyond the (session, harness, turnIndex) key. */ +export interface SessionTurnAppend { + streamId: string; + turnId?: string; + agentSessionId?: string; + sandboxId?: string; + references?: TurnReference[]; + traceId?: string; + spanId?: string; + startTime?: string; +} + +export interface SessionTurnCompletion { + agentSessionId?: string; + endTime: string; +} + +export type CompleteSessionTurnFn = ( + sessionId: string, + turnIndex: number, + turn: SessionTurnCompletion, + deps: DurableContinuityDeps, +) => Promise; + +export interface AppendSessionTurnFn { + ( + sessionId: string, + harness: string, + turnIndex: number, + turn: SessionTurnAppend, + deps: DurableContinuityDeps, + ): Promise; + complete?: CompleteSessionTurnFn; +} + /** - * Read the durable row back into `store` for ONE harness, so a resume after a runner restart - * (the in-memory map is empty) still sees a prior turn's eligibility exactly as if the process - * had stayed up. Best-effort: any failure (row absent, API unreachable, storage disabled) - * leaves `store` untouched — the caller then behaves exactly as it does today with an empty - * store (cold replay), never throwing for a missing durable record. + * Fetch the LATEST turn for a session, optionally scoped to one harness. Ordered by + * `turn_index DESC, id DESC` via `windowing: {limit: 1, order: "descending"}`. Returns undefined + * on any failure (row absent, API unreachable) — every caller treats this as best-effort, + * degrading to cold replay. */ -export async function hydrateHarnessSessionFromDurable( +export async function fetchLatestSessionTurn( sessionId: string, - harness: string, - store: SessionContinuityStore, + harness: string | undefined, deps: DurableContinuityDeps, -): Promise { +): Promise { const log = deps.log ?? defaultLog; const doFetch = deps.fetchImpl ?? fetch; const base = deps.apiBase ?? apiBase(); try { - const res = await doFetch( - `${base}/sessions/states/?session_id=${encodeURIComponent(sessionId)}`, - { - method: "GET", - headers: { authorization: deps.authorization }, + const res = await doFetch(`${base}/sessions/turns/query`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: deps.authorization, }, - ); + body: JSON.stringify({ + query: { + session_id: sessionId, + ...(harness ? { harness_kind: harness } : {}), + }, + windowing: { limit: 1, order: "descending" }, + }), + }); if (!res.ok) { - log(`hydrate HTTP ${res.status} session=${sessionId} — starting cold`); - return; - } - const body = (await res.json()) as { - session_state?: SessionStateWire | null; - }; - const data = body.session_state?.data; - - // Restore the cross-harness latest-turn counter FIRST, independent of whether THIS harness - // has a record: it may exceed this harness's own turn (another harness ran the later turn), - // and understating it would make `isHarnessLoadEligible` wrongly pass a stale harness after - // a restart. - if (data?.latest_turn_index !== undefined) { - store.restoreLatestTurn(sessionId, data.latest_turn_index); - } - - const record = data?.harness_sessions?.[harness]; - if (!record?.agent_session_id || record.turn_index === undefined) { - return; + log( + `latest-turn HTTP ${res.status} session=${sessionId} harness=${harness ?? "-"}`, + ); + return undefined; } - // Only seed the store when it has NOTHING for this (session, harness) yet — a live - // in-process record (this restart never happened) is always fresher than the durable - // mirror and must not be clobbered by a stale read. - if (store.get(sessionId, harness)) return; - store.record( - sessionId, - harness, - record.agent_session_id, - record.turn_index, - ); - log( - `hydrated session=${sessionId} harness=${harness} turn=${record.turn_index}`, - ); + const body = (await res.json()) as SessionTurnsQueryResponseWire; + return body.turns?.[0]; } catch (err) { log( - `hydrate failed session=${sessionId} harness=${harness}: ${String(err instanceof Error ? err.message : err).slice(0, 160)}`, + `latest-turn failed session=${sessionId} harness=${harness ?? "-"}: ${String(err instanceof Error ? err.message : err).slice(0, 160)}`, ); + return undefined; } } /** - * Write this harness's just-completed-turn record forward to the durable row (read-modify-write - * on `harness_sessions`, full-PUT semantics — see module doc). Call AFTER - * `SessionContinuityStore.record()` so the value persisted matches the in-memory one exactly. - * Best-effort and fire-and-forget from the caller's perspective: a failure here only means the - * NEXT restart cold-replays this harness turn, never a broken current turn. + * Read the durable turn log back into `store` for ONE harness, so a resume after a runner + * restart (the in-memory map is empty) still sees a prior turn's eligibility exactly as if the + * process had stayed up. Best-effort: any failure (no turns yet, API unreachable) leaves + * `store` untouched — the caller then behaves exactly as it does today with an empty store + * (cold replay), never throwing for a missing durable record. */ -export async function syncHarnessSessionDurable( +export async function hydrateHarnessSessionFromDurable( sessionId: string, harness: string, - agentSessionId: string, + store: SessionContinuityStore, + deps: DurableContinuityDeps, +): Promise { + const log = deps.log ?? defaultLog; + + // Restore the cross-harness latest-turn counter FIRST, independent of whether THIS harness + // authored it: another harness may have run the later turn, and understating the counter + // would make `isHarnessLoadEligible` wrongly pass a stale harness after a restart. + const latestOverall = await fetchLatestSessionTurn( + sessionId, + undefined, + deps, + ); + if (latestOverall?.turn_index !== undefined) { + store.restoreLatestTurn(sessionId, latestOverall.turn_index); + } + + // Only seed the store when it has NOTHING for this (session, harness) yet — a live + // in-process record (this restart never happened) is always fresher than the durable + // mirror and must not be clobbered by a stale read. + if (store.get(sessionId, harness)) return; + + const latestForHarness = + latestOverall?.harness_kind === harness + ? latestOverall + : await fetchLatestSessionTurn(sessionId, harness, deps); + // Row existence proves only that a turn started. Native continuation is trustworthy only after + // `end_time` is set. + if ( + !latestForHarness?.agent_session_id || + latestForHarness.turn_index === undefined || + !latestForHarness.end_time + ) { + return; + } + store.record( + sessionId, + harness, + latestForHarness.agent_session_id, + latestForHarness.turn_index, + ); + log( + `hydrated session=${sessionId} harness=${harness} turn=${latestForHarness.turn_index}`, + ); +} + +/** Complete a started row once; retries leave the first completion unchanged. */ +export async function completeSessionTurn( + sessionId: string, turnIndex: number, + turn: SessionTurnCompletion, deps: DurableContinuityDeps, ): Promise { const log = deps.log ?? defaultLog; const doFetch = deps.fetchImpl ?? fetch; const base = deps.apiBase ?? apiBase(); try { - const getRes = await doFetch( - `${base}/sessions/states/?session_id=${encodeURIComponent(sessionId)}`, - { method: "GET", headers: { authorization: deps.authorization } }, - ); - const existingData: SessionStateDataWire = getRes.ok - ? (((await getRes.json()) as { session_state?: SessionStateWire | null }) - .session_state?.data ?? {}) - : {}; - const existing = existingData.harness_sessions ?? {}; - - const merged: Record = { - ...existing, - [harness]: { - agent_session_id: agentSessionId, - turn_index: turnIndex, + const res = await doFetch(`${base}/sessions/turns/complete`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: deps.authorization, }, - }; - - // Never regress the cross-harness counter: another harness may already have persisted a - // higher latest turn. Take the max so the durable `latest_turn_index` is monotonic. - const latestTurnIndex = Math.max( - existingData.latest_turn_index ?? -1, - turnIndex, + body: JSON.stringify({ + session_id: sessionId, + turn_index: turnIndex, + ...(turn.agentSessionId + ? { agent_session_id: turn.agentSessionId } + : {}), + end_time: turn.endTime, + }), + }); + log( + `complete ${res.ok ? "OK" : `HTTP ${res.status}`} session=${sessionId} turn=${turnIndex}`, ); + } catch (err) { + log( + `complete failed session=${sessionId} turn=${turnIndex}: ${String(err instanceof Error ? err.message : err).slice(0, 160)}`, + ); + } +} - const res = await doFetch( - `${base}/sessions/states/?session_id=${encodeURIComponent(sessionId)}`, - { - method: "PUT", - headers: { - "content-type": "application/json", - authorization: deps.authorization, - }, - body: JSON.stringify({ - data: { - ...existingData, - latest_agent_session_id: agentSessionId, - latest_turn_index: latestTurnIndex, - harness_sessions: merged, - }, - }), +/** Start one ledger row per conversation turn; approval resumes reuse it through the benign 409. */ +export const appendSessionTurn: AppendSessionTurnFn = async function appendSessionTurn( + sessionId, + harness, + turnIndex, + turn, + deps, +): Promise { + const log = deps.log ?? defaultLog; + const doFetch = deps.fetchImpl ?? fetch; + const base = deps.apiBase ?? apiBase(); + try { + const res = await doFetch(`${base}/sessions/turns/`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: deps.authorization, }, - ); + body: JSON.stringify({ + session_id: sessionId, + stream_id: turn.streamId, + ...(turn.turnId ? { turn_id: turn.turnId } : {}), + turn_index: turnIndex, + harness_kind: harness, + ...(turn.agentSessionId + ? { agent_session_id: turn.agentSessionId } + : {}), + ...(turn.sandboxId ? { sandbox_id: turn.sandboxId } : {}), + ...(turn.references?.length ? { references: turn.references } : {}), + ...(turn.traceId ? { trace_id: turn.traceId } : {}), + ...(turn.spanId ? { span_id: turn.spanId } : {}), + ...(turn.startTime ? { start_time: turn.startTime } : {}), + }), + }); + if (res.status === 409) return; log( - `sync ${res.ok ? "OK" : `HTTP ${res.status}`} session=${sessionId} harness=${harness} turn=${turnIndex}`, + `append ${res.ok ? "OK" : `HTTP ${res.status}`} session=${sessionId} harness=${harness} turn=${turnIndex}`, ); } catch (err) { log( - `sync failed session=${sessionId} harness=${harness}: ${String(err instanceof Error ? err.message : err).slice(0, 160)}`, + `append failed session=${sessionId} harness=${harness}: ${String(err instanceof Error ? err.message : err).slice(0, 160)}`, ); } -} +}; + +appendSessionTurn.complete = completeSessionTurn; diff --git a/services/runner/src/permission-plan.ts b/services/runner/src/permission-plan.ts index 012cda183e..b9ec102dcc 100644 --- a/services/runner/src/permission-plan.ts +++ b/services/runner/src/permission-plan.ts @@ -57,11 +57,19 @@ export interface PiBuiltinIdentity { readOnly: boolean; } +export interface StoredPermissionDecision { + decision: "allow" | "deny"; + interactionToken?: string; +} + export type Verdict = - { kind: "allow" } | { kind: "deny" } | { kind: "pendingApproval" }; + | { kind: "allow" | "deny"; interactionToken?: string } + | { kind: "pendingApproval" }; export interface StoredPermissionDecisions { - take(gate: GateDescriptor): "allow" | "deny" | undefined; + take( + gate: GateDescriptor, + ): "allow" | "deny" | StoredPermissionDecision | undefined; } const PERMISSION_MODES: readonly PermissionMode[] = [ @@ -145,8 +153,18 @@ export function decide( if (permission === "allow") return { kind: "allow" }; const storedDecision = stored.take(gate); - if (storedDecision === "allow") return { kind: "allow" }; - if (storedDecision === "deny") return { kind: "deny" }; + const decision = + typeof storedDecision === "string" + ? storedDecision + : storedDecision?.decision; + if (decision === "allow" || decision === "deny") { + return { + kind: decision, + ...(typeof storedDecision === "object" && storedDecision.interactionToken + ? { interactionToken: storedDecision.interactionToken } + : {}), + }; + } return { kind: "pendingApproval" }; } @@ -170,20 +188,6 @@ export function storedDecisionKeyShape( }; } -export class PendingApprovalLatch { - private acquired = false; - - tryAcquire(): boolean { - if (this.acquired) return false; - this.acquired = true; - return true; - } - - get held(): boolean { - return this.acquired; - } -} - function normalizeRules(rawRules: unknown): PermissionPlan["rules"] { if (!Array.isArray(rawRules)) return []; const rules: PermissionPlan["rules"] = []; diff --git a/services/runner/src/protocol.ts b/services/runner/src/protocol.ts index bce0dfbc89..168ce5c53e 100644 --- a/services/runner/src/protocol.ts +++ b/services/runner/src/protocol.ts @@ -365,6 +365,14 @@ export type AgentEvent = kind: "user_approval" | "user_input" | "client_tool"; payload?: unknown; } + // The durable answer half of an interaction_request, emitted when the runner forwards the + // human decision to the harness. Its id matches the request so hydration can overlay the answer. + | { + type: "interaction_response"; + id: string; + kind: "user_approval"; + payload?: unknown; + } // One-way generative-UI payloads (not tied to a tool result). `data` -> Vercel `data-`, // `file` -> Vercel `file`. | { type: "data"; name: string; data: unknown; transient?: boolean } @@ -519,6 +527,13 @@ export interface AgentRunRequest { * the runner can include it in heartbeat and record-ingest calls. Absent otherwise. */ projectId?: string; + /** + * The session's `session_streams` row id, captured for free from the alive-watchdog's + * heartbeat response (`sessions/alive.ts`) and threaded here before the engine runs. Present + * only for session-owned streaming runs; consumed at the turn-append write site + * (`engines/sandbox_agent.ts`) as `session_turns.stream_id`. + */ + streamId?: string; } export interface AgentRunResult { diff --git a/services/runner/src/responder.ts b/services/runner/src/responder.ts index 21b5ec328a..9a36ae8ddc 100644 --- a/services/runner/src/responder.ts +++ b/services/runner/src/responder.ts @@ -7,13 +7,17 @@ */ import type { AgentRunRequest, ContentBlock } from "./protocol.ts"; -import { DEFERRED_NOT_EXECUTED_PREFIX } from "./tracing/otel.ts"; +import { + APPROVED_EXECUTION_RESULT_UNKNOWN, + DEFERRED_NOT_EXECUTED_PREFIX, +} from "./tracing/otel.ts"; import { decide, effectivePermission, storedDecisionKeyShape, type GateDescriptor, type PermissionPlan, + type StoredPermissionDecision, type StoredPermissionDecisions, type Verdict, } from "./permission-plan.ts"; @@ -253,13 +257,15 @@ export class ConversationDecisions implements StoredPermissionDecisions { } /** allow|deny for this exact call (name + canonical args), consumed on first take. */ - take(gate: GateDescriptor): "allow" | "deny" | undefined { + take( + gate: GateDescriptor, + ): PermissionDecision | StoredPermissionDecision | undefined { const key = approvedCallKey(gate.toolName, gate.args); if (!key) return undefined; const queue = this.decisionQueues.get(key); if (!queue || queue.length === 0) return undefined; const value = queue[0]; - if (!isPermissionDecision(value)) return undefined; + if (!isStoredPermissionDecision(value)) return undefined; queue.shift(); if (queue.length === 0) this.decisionQueues.delete(key); return value; @@ -335,7 +341,11 @@ export class ApprovalResponder implements Responder { if (permission === "ask") { const storedDecision = this.decisions.take(request.gate); - if (storedDecision === "deny") return { kind: "deny" }; + const decision = + typeof storedDecision === "string" + ? storedDecision + : storedDecision?.decision; + if (decision === "deny") return { kind: "deny" }; } return { kind: "pendingApproval" }; } @@ -361,7 +371,7 @@ export function extractApprovalDecisions( const decisions = new Map(); const callShapeById = buildCallShapeIndex(request); for (const block of toolResultBlocks(request)) { - const decision = approvalDecisionOf(block); + const decision = storedApprovalDecisionOf(block); if (decision === undefined) continue; const argsKey = coldReplayKey(block, callShapeById); if (!argsKey) continue; @@ -395,10 +405,9 @@ export function extractClientToolOutputs( const callShapeById = buildCallShapeIndex(request); for (const block of currentTurnToolResultBlocks(request)) { if (approvalDecisionOf(block) !== undefined) continue; // an approval, not a client output - // A sibling force-settled while another interaction paused this turn was NOT executed - // (`DEFERRED_NOT_EXECUTED`). It must not fulfill the model's retry of the same call, or the - // re-request resolves against the deferral and never re-parks (no new widget). - if (isDeferredNotExecuted(block)) continue; + // Pause terminalization sentinels are not client outputs: deferred work may re-park, while + // approved work with an unknown result must not be fulfilled or retried. + if (isPauseSyntheticResult(block)) continue; const argsKey = coldReplayKey(block, callShapeById); if (!argsKey) continue; const list = outputs.get(argsKey) ?? []; @@ -483,38 +492,58 @@ function isPermissionDecision(value: unknown): value is PermissionDecision { return value === "allow" || value === "deny"; } +function isStoredPermissionDecision( + value: unknown, +): value is PermissionDecision | StoredPermissionDecision { + if (isPermissionDecision(value)) return true; + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + return isPermissionDecision( + (value as { decision?: unknown }).decision, + ); +} + /** - * A sibling force-settle result: the turn paused on another interaction, so this client tool was - * never executed and carries the `DEFERRED_NOT_EXECUTED` sentinel. It is not a browser output — the - * model is meant to re-issue the same call, which must re-park rather than resolve against this. + * Pause terminalization sentinels are runner bookkeeping, not browser outputs. A deferred call + * may safely re-park, while an approved call with an unobserved result must never be retried. */ -function isDeferredNotExecuted(block: ContentBlock): boolean { +function isPauseSyntheticResult(block: ContentBlock): boolean { return ( typeof block.output === "string" && - block.output.startsWith(DEFERRED_NOT_EXECUTED_PREFIX) + (block.output.startsWith(DEFERRED_NOT_EXECUTED_PREFIX) || + block.output === APPROVED_EXECUTION_RESULT_UNKNOWN) ); } /** - * An approval reply uses an `{ approved: boolean }` envelope (the Vercel adapter's - * `_approval_response_blocks` shape), unique to a permission response. Returns - * `"allow"`/`"deny"` for one, or `undefined` for any other tool_result (a real browser/client - * output). + * Approval envelopes are the only tool results treated as permission decisions. Preserve their + * interaction token so a cold responder can resolve the original row after replying successfully; + * real browser/client outputs remain outside this path. */ -export function approvalDecisionOf( +function storedApprovalDecisionOf( block: ContentBlock, -): PermissionDecision | undefined { +): StoredPermissionDecision | undefined { if (!block || block.type !== "tool_result") return undefined; const output = block.output; - if ( - output && - typeof output === "object" && - !Array.isArray(output) && - typeof (output as { approved?: unknown }).approved === "boolean" - ) { - return (output as { approved: boolean }).approved ? "allow" : "deny"; + if (!output || typeof output !== "object" || Array.isArray(output)) { + return undefined; } - return undefined; + const envelope = output as { + approved?: unknown; + interactionToken?: unknown; + }; + if (typeof envelope.approved !== "boolean") return undefined; + return { + decision: envelope.approved ? "allow" : "deny", + ...(typeof envelope.interactionToken === "string" && envelope.interactionToken + ? { interactionToken: envelope.interactionToken } + : {}), + }; +} + +export function approvalDecisionOf( + block: ContentBlock, +): PermissionDecision | undefined { + return storedApprovalDecisionOf(block)?.decision; } /** diff --git a/services/runner/src/server.ts b/services/runner/src/server.ts index d2ddc2987e..93beeb2591 100644 --- a/services/runner/src/server.ts +++ b/services/runner/src/server.ts @@ -37,6 +37,8 @@ import { runSandboxAgent, runTurn, shouldPark, + type ParkedApproval, + type ResumeApprovalInput, type RunTurnOptions, type SessionEnvironment, } from "./engines/sandbox_agent.ts"; @@ -332,6 +334,9 @@ export async function runWithKeepalive( ): Promise { const { engine, pool, config, clientGone } = ctx; const sessionId = request.sessionId?.trim(); + // Every execution carries an id: callers that omit `turnId` get one minted here, so the + // turns-ledger append and interaction rows are never written without their execution id. + request.turnId = request.turnId?.trim() || randomUUID(); // Track whether anything reached the client on this streaming edge. A live continuation/resume // that fails AFTER emitting (a partial answer or an error event) must NOT retry cold: the client @@ -412,21 +417,34 @@ export async function runWithKeepalive( } }; - // Whether a paused turn holds a single, parkable permission gate (a Claude ACP gate or a Pi - // ACP gate). Only such a gate carries a `respondPermission`-answerable id; a client-tool MCP - // pause never records `parkedApproval`, and more than one pending gate cannot be answered by - // the single-gate resume — both stay on the cold path, logged. + // Whether a paused turn is fully parkable: every pending gate is a parkable ACP permission gate + // (Claude ACP or Pi ACP) that carries a `respondPermission`-answerable id, so the warm resume + // can answer them all. A turn parks when it has at least one parked gate AND every pending gate + // is parkable: no client-tool MCP pause (unanswerable across a turn boundary), and no approval + // gate that lacked a resumable id. A mixed or partly-unresumable set stays on the cold path, + // which is the only path that can multiplex it. Any count of parkable gates parks — a turn with + // several parallel gates is answered by several `respondPermission` calls on the resume. const approvalToPark = ( env: SessionEnvironment, result: AgentRunResult, ): boolean => { if (result.stopReason !== "paused") return false; - if (!env.parkedApproval) { + if (env.parkedApprovals.size === 0) { klog(`non-parkable-gate-no-park key=${key}`); return false; } - if ((env.approvalGateCount ?? 0) > 1) { - klog(`multi-gate-no-park key=${key} gates=${env.approvalGateCount}`); + if ((env.nonParkablePauseCount ?? 0) > 0) { + klog( + `mixed-gate-no-park key=${key} parkable=${env.parkedApprovals.size} ` + + `nonParkable=${env.nonParkablePauseCount}`, + ); + return false; + } + if (env.parkedApprovals.size !== (env.approvalGateCount ?? 0)) { + klog( + `unresumable-gate-no-park key=${key} parkable=${env.parkedApprovals.size} ` + + `gates=${env.approvalGateCount}`, + ); return false; } // An approval park waits for the HUMAN, who is still on the page even if the streaming client @@ -445,15 +463,22 @@ export async function runWithKeepalive( // one destroy, so no double-destroy is possible. The promise already carries runTurn's // swallowing catch, so no unhandled rejection is introduced. const watchParkedPrompt = (env: SessionEnvironment): void => { - const promptPromise = env.parkedApproval?.promptPromise; const entry = pool.get(key); - if (!promptPromise || !entry || entry.environment !== env) return; - promptPromise.catch(() => { - const current = pool.get(key); - if (current !== entry || current.state !== "awaiting_approval") return; - klog(`parked-prompt-rejected key=${key}; evict`); - void pool.evict(key, "parked-prompt-rejected", "failed-turn"); - }); + if (!entry || entry.environment !== env) return; + // Watch the WHOLE parked set: a turn is pending until every gate resolves, so a rejection of + // ANY parked prompt means the harness or sandbox died mid-park and the dead session must be + // evicted. Every parked gate shares the one turn prompt promise, so the catches are on the + // same promise; the eviction is identity-checked and idempotent, so repeated catches are safe. + for (const parked of env.parkedApprovals.values()) { + const promptPromise = parked.promptPromise; + if (!promptPromise) continue; + promptPromise.catch(() => { + const current = pool.get(key); + if (current !== entry || current.state !== "awaiting_approval") return; + klog(`parked-prompt-rejected key=${key}; evict`); + void pool.evict(key, "parked-prompt-rejected", "failed-turn"); + }); + } }; // Park a freshly cold-acquired environment (new pool slot) as approval / idle, or tear it down. @@ -657,29 +682,52 @@ export async function runWithKeepalive( // history fingerprint (an edited transcript must not continue wrongly), and a hard mount-expiry // bound — if the parked session's mount credentials are past expiry, its durable cwd can no // longer be written, so evict to cold. + // Split parallel parked gates by tool-call id. Any non-empty answer set resumes live; untouched + // gates stay pending in the same process and are carried into the next approval park. const parked = existing.environment.parkedApproval; - const decision = parked - ? approvalDecisionForToolCall(request, parked.toolCallId) - : undefined; - const priorFp = historyFingerprint(priorConversation(request)); + const parkedList = [...existing.environment.parkedApprovals.values()]; + const resumeDecisions: ResumeApprovalInput[] = []; + const carriedForward: ParkedApproval[] = []; let mismatch: string | undefined; - if ( - !parked || - (parked.gateType !== "claude-acp-permission" && - parked.gateType !== "pi-acp-permission") - ) { - // Defensive: only a parkable gate type (Claude ACP or Pi ACP) ever parks here. Both - // resume via `respondPermission` on the live session; the daemon maps the reply by kind. - mismatch = "unrecognized-gate-type"; - } else if (!decision) { - mismatch = "no-matching-approval"; // fresh user text, or an approval for another id - } else if (priorFp !== existing.historyFingerprint) { - mismatch = "history"; - } else if (mountCredentialsExpired(existing.credentialEpoch)) { - mismatch = "credentials-expired"; + if (parkedList.length === 0) { + mismatch = "no-parked-gate"; + } else { + for (const gate of parkedList) { + if ( + gate.gateType !== "claude-acp-permission" && + gate.gateType !== "pi-acp-permission" + ) { + // Defensive: only a parkable gate type (Claude ACP or Pi ACP) ever parks here. Both + // resume via `respondPermission` on the live session; the daemon maps the reply by kind. + mismatch = "unrecognized-gate-type"; + break; + } + const decision = approvalDecisionForToolCall(request, gate.toolCallId); + if (!decision) { + carriedForward.push(gate); + continue; + } + resumeDecisions.push({ + permissionId: gate.permissionId, + reply: decision === "allow" ? "once" : "reject", + toolCallId: gate.toolCallId, + toolName: gate.toolName, + args: gate.args, + interactionToken: gate.interactionToken, + promptPromise: gate.promptPromise, + }); + } + } + const priorFp = historyFingerprint(priorConversation(request)); + if (!mismatch) { + if (priorFp !== existing.historyFingerprint) { + mismatch = "history"; + } else if (mountCredentialsExpired(existing.credentialEpoch)) { + mismatch = "credentials-expired"; + } } - if (mismatch || !parked || !decision) { + if (mismatch || resumeDecisions.length === 0) { klog( `approval-mismatch (${mismatch ?? "unknown"}) key=${key}; evict + cold`, ); @@ -693,10 +741,14 @@ export async function runWithKeepalive( const live = pool.checkoutApproval(key); if (live) { - const reply = decision === "allow" ? "once" : "reject"; + const approveCount = resumeDecisions.filter( + (d) => d.reply === "once", + ).length; + const rejectCount = resumeDecisions.length - approveCount; klog( - `${reply === "once" ? "resume-approve" : "resume-reject"} key=${key} ` + - `tool=${parked.toolName ?? "?"}`, + `resume key=${key} gates=${parkedList.length} answered=${resumeDecisions.length} ` + + `carried=${carriedForward.length} ` + + `approve=${approveCount} reject=${rejectCount} tool=${parked?.toolName ?? "?"}`, ); let result: AgentRunResult; try { @@ -710,15 +762,7 @@ export async function runWithKeepalive( signal, { approvalParkMode: true, - resume: { - permissionId: parked.permissionId, - reply, - toolCallId: parked.toolCallId, - toolName: parked.toolName, - args: parked.args, - interactionToken: parked.interactionToken, - promptPromise: parked.promptPromise, - }, + resume: { decisions: resumeDecisions, carriedForward }, }, ); } catch (err) { @@ -796,25 +840,37 @@ const runAgent: RunAgent = (request, emit, signal, options) => { }; /** - * The durable interaction token of a parked approval gate this request answers in-band, if - * any. The turn-start cancel-stale sweep must spare it: the gate belongs to the PREVIOUS turn - * (so the sweep's own `turn_id` exemption misses it), an in-band answer never transitions the - * row off `pending` (only the interactions-plane respond endpoint does), and the resume - * resolves the token after consuming the decision. Swept first, the granted gate's record - * lands as `cancelled` and the resolve 404s. + * The stale-cancel exemptions for a parked approval set. A partial live resume owns both the + * answered and carried gates, so any answer spares every parked token; zero answers spare none. */ -function inBandAnswerToken(request: AgentRunRequest): string | undefined { +export function staleInteractionExemptTokens( + request: AgentRunRequest, + parked: ReadonlyMap, +): string[] | undefined { + const hasAnswer = [...parked.values()].some( + (gate) => + approvalDecisionForToolCall(request, gate.toolCallId) !== undefined, + ); + return hasAnswer + ? [...parked.values()].map((gate) => gate.interactionToken) + : undefined; +} + +/** + * A live partial resume owns the whole parked set, including unanswered gates it carries into the + * next pause. Once any parked gate is answered, the stale sweep must spare every parked token; a + * zero-answer request owns none and receives no exemptions. + */ +function inBandAnswerTokens(request: AgentRunRequest): string[] | undefined { const sessionId = request.sessionId?.trim(); if (!sessionId) return undefined; const provider = resolveKeepaliveDispatch(request, keepaliveConfigs); if (!provider) return undefined; const parked = keepalivePools[provider].awaitingApproval(sessionId)?.environment - .parkedApproval; - if (!parked) return undefined; - return approvalDecisionForToolCall(request, parked.toolCallId) !== undefined - ? parked.interactionToken - : undefined; + .parkedApprovals; + if (!parked || parked.size === 0) return undefined; + return staleInteractionExemptTokens(request, parked); } /** @@ -859,6 +915,9 @@ async function runAndStreamWithApiBaseResolved( const sessionOwned = isSessionOwned(request); const sessionId = request.sessionId!; const turnId = resolveTurnId(request); + // Write the resolved id back: every downstream reader of `request.turnId` (the turns-ledger + // append, interaction rows) must see the SAME execution id the alive-lock and records use. + request.turnId = turnId; // Diagnostic: surface whether the session-owned persist/alive path is entered and // whether the invoke credential arrived. Empty cred => heartbeat/persist would 401. @@ -906,20 +965,31 @@ async function runAndStreamWithApiBaseResolved( // The runner authenticates session calls AS the invoke caller (the run credential), // refreshing it for the turn's lifetime — never the admin key. Project scope is // resolved server-side from the credential, so no project_id rides the request. - const watchdog = startAliveWatchdog( + // + // onInterrupted (W7.4): a cancel/steer/kill against this session (via + // `POST /sessions/streams/` or the runner's own `/kill`) drops this turn's alive lock. + // The next heartbeat surfaces that as `is_current_turn: false`; wiring it to + // `controller.abort()` is what makes the control-plane signal actually reach this + // in-flight run — before this, a session-owned run's controller was never aborted. + // Awaited (WP3) so the first heartbeat's stream_id is ready before the turn starts. + const watchdog = await startAliveWatchdog( sessionId, turnId, runCredential(request), + () => controller.abort(), ); aliveWatchdog = watchdog; + // The heartbeat response already carries the session_streams row id — free, no extra + // round-trip. Thread it onto the request so the engine's turn-append write has it. + request.streamId = watchdog.streamId(); // A new turn supersedes any prior turn's unanswered gate: cancel stale pending // interactions (sparing this turn's own, plus a parked gate this turn answers in-band — // the resume resolves that one). Best-effort, never blocks the turn. - const answeredToken = inBandAnswerToken(request); + const answeredTokens = inBandAnswerTokens(request); void cancelStaleInteractions( sessionId, turnId, - answeredToken ? [answeredToken] : undefined, + answeredTokens, watchdog.credential, ); // Deny-set from THIS run's resolved provider keys + run credential (not process env, @@ -933,6 +1003,8 @@ async function runAndStreamWithApiBaseResolved( watchdog.credential, liveEmit, seedForRun(request), + turnId, + request.runContext?.trace?.span_id, ); // Record the inbound user turn first so the session record is the full conversation, // not just agent output. Interaction replies ride tool_result blocks (no text) and are diff --git a/services/runner/src/sessions/alive.ts b/services/runner/src/sessions/alive.ts index dd63c4681f..d5843c8611 100644 --- a/services/runner/src/sessions/alive.ts +++ b/services/runner/src/sessions/alive.ts @@ -49,13 +49,20 @@ function log(msg: string): void { * container `replica_id` (refreshes `owner` affinity) and the `turn_id` (proves alive ownership). * Authenticates AS the invoke caller (the run credential) — project scope is resolved server-side * from that credential, so no `project_id` rides the request. + * + * Returns both signals the one response body carries: `streamId` (the `session_streams` row + * uuid — the free gift of a call the runner already makes every turn, no new round-trip) and + * `interrupted: true` when the API reports `is_current_turn: false` (a cancel/steer/kill took + * this turn's alive/running lock since the last beat — W7.4, the control-signal path). A + * network/HTTP failure yields `{ streamId: undefined, interrupted: false }` (fail-open: a + * transient API blip must neither abort a healthy run nor fabricate a stream id). */ async function sendHeartbeat( sessionId: string, turnId: string, authorization: string, isRunning = true, -): Promise { +): Promise<{ streamId: string | undefined; interrupted: boolean }> { try { const url = `${apiBase()}/sessions/streams/heartbeat`; const res = await fetch(url, { @@ -73,15 +80,27 @@ async function sendHeartbeat( }); if (!res.ok) { log(`heartbeat HTTP ${res.status} session=${sessionId} turn=${turnId}`); - } else { - log( - `heartbeat OK session=${sessionId} turn=${turnId} running=${isRunning}`, - ); + return { streamId: undefined, interrupted: false }; } + const body = (await res.json()) as { + stream?: { id?: unknown } | null; + is_current_turn?: unknown; + }; + const rawStreamId = body.stream?.id; + const streamId = + typeof rawStreamId === "string" && rawStreamId.length > 0 + ? rawStreamId + : undefined; + const interrupted = body.is_current_turn === false; + log( + `heartbeat OK session=${sessionId} turn=${turnId} running=${isRunning}${interrupted ? " INTERRUPTED" : ""}`, + ); + return { streamId, interrupted }; } catch (err) { log( `heartbeat failed session=${sessionId} turn=${turnId}: ${String(err instanceof Error ? err.message : err).slice(0, 120)}`, ); + return { streamId: undefined, interrupted: false }; } } @@ -132,20 +151,49 @@ export async function claimSessionOwnership( * inherits ownership via `turnId`. This watchdog heartbeats the API on the contract interval, * keeping the lock's TTL refreshed and the stream row `running`. * - * Returns a `release()` function the caller MUST await in the run's `finally` so the - * heartbeat stops and the session row is marked `ended`. + * `onInterrupted` (W7.4) fires AT MOST ONCE, the first time a beat reports the lock was taken + * by a cancel/steer/kill since the last beat — the caller wires this to `controller.abort()` so + * a control-plane cancel actually reaches the in-flight run. Before this, losing the alive lock + * was invisible to the runner process: a heartbeat's nx=True re-acquire silently re-armed the + * same lock under the same turn_id and the run continued as if nothing happened. + * + * Awaits the FIRST heartbeat (only) so its response's `stream_id` is ready before the caller + * starts the turn — every later heartbeat stays fire-and-forget. Returns a `release()` function + * the caller MUST await in the run's `finally` so the heartbeat stops and the row is marked + * `ended`. */ -export function startAliveWatchdog( +export async function startAliveWatchdog( sessionId: string, turnId: string, authorization: string, -): { release: () => Promise; credential: () => string } { + onInterrupted?: () => void, +): Promise<{ + release: () => Promise; + credential: () => string; + streamId: () => string | undefined; +}> { // The run credential is an ephemeral Secret (~15-min TTL). Hold it mutably and refresh it // every Nth heartbeat (re-/check mints a fresh-expiry token) so a long turn never 401s. let credential = authorization; let beats = 0; + let interruptedFired = false; + let streamId: string | undefined; + + const handleBeat = (result: { + streamId: string | undefined; + interrupted: boolean; + }): void => { + if (result.streamId) streamId = result.streamId; + if (result.interrupted && !interruptedFired) { + interruptedFired = true; + log(`interrupted session=${sessionId} turn=${turnId} -> aborting`); + onInterrupted?.(); + } + }; - void sendHeartbeat(sessionId, turnId, credential); + // Await the FIRST beat so streamId is ready before the caller starts the turn. + const first = await sendHeartbeat(sessionId, turnId, credential); + handleBeat(first); const interval = setInterval(() => { void (async () => { @@ -154,7 +202,7 @@ export function startAliveWatchdog( const fresh = await refreshCredential(apiBase(), credential); if (fresh) credential = fresh; } - void sendHeartbeat(sessionId, turnId, credential); + handleBeat(await sendHeartbeat(sessionId, turnId, credential)); })(); }, REFRESH_INTERVAL_MS); @@ -170,5 +218,6 @@ export function startAliveWatchdog( await sendHeartbeat(sessionId, turnId, credential, false); }, credential: () => credential, + streamId: () => streamId, }; } diff --git a/services/runner/src/sessions/interactions.ts b/services/runner/src/sessions/interactions.ts index dcbaa67309..2cd9bb615d 100644 --- a/services/runner/src/sessions/interactions.ts +++ b/services/runner/src/sessions/interactions.ts @@ -8,14 +8,17 @@ import { apiBase } from "../apiBase.ts"; export type InteractionKind = "user_approval" | "user_input" | "client_tool"; +export type InteractionResolution = { + verdict: "approved" | "denied"; + tool_call_id: string; +}; + /** A platform entity reference (the API `Reference` shape). */ type Reference = { id?: string; slug?: string; version?: string }; export type InteractionData = { request?: { tool: string; args: unknown }; - // The workflow references that identify which revision THIS turn is running, so the - // respond invoke re-resolves the SAME workflow. We store the references (pointers), not - // the revision data itself — respond resolves the live revision from them at invoke time. + // Optional attribution for out-of-band re-invocation; inbox/audit rows exist without it. references?: Record; }; @@ -98,6 +101,7 @@ export async function resolveInteraction( sessionId: string, token: string, auth: () => string, + resolution?: InteractionResolution, ): Promise { try { const res = await fetch(`${apiBase()}/sessions/interactions/transition`, { @@ -107,6 +111,7 @@ export async function resolveInteraction( session_id: sessionId, token, status: "resolved", + ...(resolution ? { resolution } : {}), }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); diff --git a/services/runner/src/sessions/persist.ts b/services/runner/src/sessions/persist.ts index 77a6c1f1d6..1006de5227 100644 --- a/services/runner/src/sessions/persist.ts +++ b/services/runner/src/sessions/persist.ts @@ -43,6 +43,8 @@ async function postEvent( eventIndex: number, sender: string, recordId?: string, + turnId?: string, + spanId?: string, ): Promise { const url = `${apiBase()}/sessions/records/ingest`; let lastErr: unknown; @@ -64,6 +66,10 @@ async function postEvent( record_source: sender, record_type: event.type, attributes: event, + // Tags the record for turn-grouping; span_id bridges to observability when + // the run has one in scope (both forward-fill only, absent is expected). + ...(turnId ? { turn_id: turnId } : {}), + ...(spanId ? { span_id: spanId } : {}), }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); @@ -94,12 +100,23 @@ export function persistEvent( sender: string = "agent", recordId?: string, redactor?: Redactor, + turnId?: string, + spanId?: string, ): void { // Redact at the sink: the durable copy is scrubbed; the live/in-memory event the harness // and the client stream still hold is untouched. const durable = redactor ? redactor.redactJson(event, "records") : event; const tail = (persistChains.get(sessionId) ?? Promise.resolve()).then(() => - postEvent(sessionId, auth, durable, eventIndex, sender, recordId), + postEvent( + sessionId, + auth, + durable, + eventIndex, + sender, + recordId, + turnId, + spanId, + ), ); persistChains.set(sessionId, tail); } @@ -137,14 +154,16 @@ const OPEN_TOOL_TTL_MS = Number(process.env.AGENTA_RECORD_TOOL_TTL_MS ?? 3000); * - message_start/delta/end and thought_* accumulate text, persisted once on *_end. * - tool_call snapshots for one id accumulate (latest args win) into a single open slot, * persisted once when a non-continuation event arrives, the TTL fires, or the turn - * drains. The record carries a stable uuid5 id so a re-sent snapshot (or a resume) - * upserts the same row rather than appending. + * drains. The record carries a turn-scoped stable uuid5 id so retries upsert within + * one execution without overwriting the same tool call from another execution. */ export function buildPersistingEmitter( sessionId: string, auth: () => string, liveEmit?: (event: AgentEvent) => void, redactor?: Redactor, + turnId?: string, + spanId?: string, ): { emit: (event: AgentEvent) => void; /** Persist an out-of-band record (e.g. the inbound user turn) through the same @@ -177,8 +196,10 @@ export function buildPersistingEmitter( event, index, "agent", - stableRecordId(sessionId, id, "tool_call"), + stableRecordId(sessionId, id, "tool_call", turnId), redactor, + turnId, + spanId, ); }; @@ -234,6 +255,8 @@ export function buildPersistingEmitter( "agent", undefined, redactor, + turnId, + spanId, ); return; } @@ -262,15 +285,19 @@ export function buildPersistingEmitter( "agent", undefined, redactor, + turnId, + spanId, ); return; } } - // A tool_result / interaction_request carries the same tool-call id as its tool_call; - // give it its own stable id (keyed on the record type) so it lands on a distinct row. + // Related tool and interaction events share correlation ids but need distinct, retry-stable + // rows, so the record type remains part of the stable id. if ( - (event.type === "tool_result" || event.type === "interaction_request") && + (event.type === "tool_result" || + event.type === "interaction_request" || + event.type === "interaction_response") && event.id ) { persistEvent( @@ -279,8 +306,10 @@ export function buildPersistingEmitter( event, eventIndex++, "agent", - stableRecordId(sessionId, event.id, event.type), + stableRecordId(sessionId, event.id, event.type, turnId), redactor, + turnId, + spanId, ); return; } @@ -294,6 +323,8 @@ export function buildPersistingEmitter( "agent", undefined, redactor, + turnId, + spanId, ); }; @@ -308,6 +339,8 @@ export function buildPersistingEmitter( sender, undefined, redactor, + turnId, + spanId, ); }; diff --git a/services/runner/src/sessions/record-id.ts b/services/runner/src/sessions/record-id.ts index 043fe3470b..6ed30a2ff8 100644 --- a/services/runner/src/sessions/record-id.ts +++ b/services/runner/src/sessions/record-id.ts @@ -28,20 +28,24 @@ function uuid5(name: string, namespace: string): string { // Project-wide root uuid5(NAMESPACE_DNS, "agenta"), sub-namespaced under "records" — the // same construction the API uses for other domains (e.g. meters). Deriving a record's id as -// uuid5(this, key) makes it deterministic, so every streamed snapshot of one tool call — and -// a resume that re-announces it — maps to one id and upserts onto one row. Non-stable records +// uuid5(this, key) makes it deterministic, so every streamed snapshot or retry within one +// execution maps to one id and upserts onto one row. Non-stable records // send no id; the backend mints a uuid4 fallback. const RECORD_NAMESPACE = uuid5("records", uuid5("agenta", NAMESPACE_DNS)); /** - * Stable record id for a tool-family record, keyed on (session, tool-call id, type). The - * `type` is part of the key so a `tool_call` and its closing `tool_result` — which share a - * tool-call id — land on two distinct rows instead of overwriting each other. + * Stable record id for a tool-family record, keyed on (session, tool-call id, type, turn). + * The turn keeps later executions from overwriting prior history, while a retry within one + * execution still upserts the same row. */ export function stableRecordId( sessionId: string, toolCallId: string, recordType: string, + turnId?: string, ): string { - return uuid5(`${sessionId}:${toolCallId}:${recordType}`, RECORD_NAMESPACE); + return uuid5( + `${sessionId}:${toolCallId}:${recordType}:${turnId ?? ""}`, + RECORD_NAMESPACE, + ); } diff --git a/services/runner/src/tracing/otel.ts b/services/runner/src/tracing/otel.ts index 5a78582b97..f446286671 100644 --- a/services/runner/src/tracing/otel.ts +++ b/services/runner/src/tracing/otel.ts @@ -65,6 +65,9 @@ export const DEFERRED_NOT_EXECUTED_PREFIX = "DEFERRED_NOT_EXECUTED"; export const TOOL_NOT_EXECUTED_PAUSED = `${DEFERRED_NOT_EXECUTED_PREFIX}: paused for another approval; retry the same call if still required.`; +export const APPROVED_EXECUTION_RESULT_UNKNOWN = + "APPROVED_EXECUTION_RESULT_UNKNOWN: the approved call started but its result was not observed before the pause ended the turn; do not assume it failed and do not retry a side-effecting call."; + // --------------------------------------------------------------------------- // Shared, process-wide tracing infrastructure // --------------------------------------------------------------------------- @@ -1086,6 +1089,8 @@ export interface SandboxAgentOtel { isExcluded: (id: string) => boolean, message: string, ): void; + /** Snapshot the ids whose tool calls have no terminal result yet. */ + openToolCallIds(): string[]; /** * Mark a tool-call id as DENIED (the runner replied `reject` to its gate). Its closing failed * result then carries `denied: true` so the egress projects `tool-output-denied` (a decline) @@ -1615,6 +1620,7 @@ export function createSandboxAgentOtel( output: () => accumulated, events: () => events, settleOpenToolCalls, + openToolCallIds: () => [...toolSpans.keys()], markToolCallDenied, usage: () => usage, }; diff --git a/services/runner/src/version.ts b/services/runner/src/version.ts index 35f2a55b67..185460f3fe 100644 --- a/services/runner/src/version.ts +++ b/services/runner/src/version.ts @@ -12,7 +12,7 @@ import pkg from "../package.json"; export const PROTOCOL_VERSION = 1; export const RUNNER_VERSION: string = pkg.version; export const ENGINES = ["sandbox-agent"] as const; -export const HARNESSES = ["pi_core", "claude", "pi_agenta"] as const; +export const HARNESS_KINDS = ["pi_core", "claude", "pi_agenta"] as const; export interface RunnerInfo { status: "ok"; @@ -30,6 +30,6 @@ export function runnerInfo(): RunnerInfo { runner: RUNNER_VERSION, protocol: PROTOCOL_VERSION, engines: ENGINES, - harnesses: HARNESSES, + harnesses: HARNESS_KINDS, }; } diff --git a/services/runner/tests/acceptance/server-contract.test.ts b/services/runner/tests/acceptance/server-contract.test.ts index a18ecfcd5a..b15a3e5685 100644 --- a/services/runner/tests/acceptance/server-contract.test.ts +++ b/services/runner/tests/acceptance/server-contract.test.ts @@ -41,8 +41,12 @@ const AUTH = { authorization: `Bearer ${TEST_TOKEN}` }; async function listen( run: RunAgent, + token: string | null = TEST_TOKEN, ): Promise<{ url: string; close: () => Promise }> { - if (!process.env[TOKEN_ENV]) process.env[TOKEN_ENV] = TEST_TOKEN; + // Force the configured token unconditionally (`null` = leave the env as the test set it, + // for the pre-set-secret / tokenless cases). A loaded dev env sets AGENTA_RUNNER_TOKEN=replace-me; + // a "set only if unset" guard would let that leak in and 401 every default-token request. + if (token !== null) process.env[TOKEN_ENV] = token; const server = createAgentServer(run); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const { port } = server.address() as AddressInfo; @@ -84,7 +88,7 @@ describe("GET /health contract", () => { it("is reachable without a bearer token even when AGENTA_RUNNER_TOKEN is set", async () => { process.env[TOKEN_ENV] = "health-gate-test"; - const s = await listen(okRun); + const s = await listen(okRun, null); try { const res = await fetch(`${s.url}/health`); assert.equal(res.status, 200); @@ -112,7 +116,7 @@ describe("POST /run auth contract", () => { it("token configured, no header → 401 {ok:false, error:/Unauthorized/}", async () => { process.env[TOKEN_ENV] = "tok-abc"; - const s = await listen(okRun); + const s = await listen(okRun, null); try { const res = await fetch(`${s.url}/run`, { method: "POST", body: "{}" }); assert.equal(res.status, 401); @@ -126,7 +130,7 @@ describe("POST /run auth contract", () => { it("token configured, wrong bearer → 401", async () => { process.env[TOKEN_ENV] = "tok-abc"; - const s = await listen(okRun); + const s = await listen(okRun, null); try { const res = await fetch(`${s.url}/run`, { method: "POST", @@ -141,7 +145,7 @@ describe("POST /run auth contract", () => { it("token configured, correct Authorization: Bearer → 200", async () => { process.env[TOKEN_ENV] = "tok-abc"; - const s = await listen(okRun); + const s = await listen(okRun, null); try { const res = await fetch(`${s.url}/run`, { method: "POST", @@ -156,7 +160,7 @@ describe("POST /run auth contract", () => { it("token configured, correct X-Agenta-Runner-Token → 200", async () => { process.env[TOKEN_ENV] = "tok-abc"; - const s = await listen(okRun); + const s = await listen(okRun, null); try { const res = await fetch(`${s.url}/run`, { method: "POST", @@ -337,7 +341,7 @@ describe("POST /kill contract", () => { it("returns 401 when token is set and no bearer supplied", async () => { process.env[TOKEN_ENV] = "kill-tok"; - const s = await listen(okRun); + const s = await listen(okRun, null); try { const res = await fetch(`${s.url}/kill`, { method: "POST", diff --git a/services/runner/tests/integration/server-smoke.test.ts b/services/runner/tests/integration/server-smoke.test.ts index a5791e96fc..74f836ad8b 100644 --- a/services/runner/tests/integration/server-smoke.test.ts +++ b/services/runner/tests/integration/server-smoke.test.ts @@ -47,8 +47,12 @@ const AUTH = { authorization: `Bearer ${TEST_TOKEN}` }; async function listen( run: RunAgent, + token: string | null = TEST_TOKEN, ): Promise<{ url: string; close: () => Promise }> { - if (!process.env[TOKEN_ENV]) process.env[TOKEN_ENV] = TEST_TOKEN; + // Force the configured token unconditionally (`null` = leave the env as the test set it, + // for the tokenless/pre-set-secret cases). A loaded dev env sets AGENTA_RUNNER_TOKEN=replace-me; + // a "set only if unset" guard would let that leak in and 401 the AUTH-bearer stream test. + if (token !== null) process.env[TOKEN_ENV] = token; const server = createAgentServer(run); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const { port } = server.address() as AddressInfo; @@ -96,7 +100,7 @@ describe("server integration — in-process server with fake engine", () => { it("POST /run returns 401 when AGENTA_RUNNER_TOKEN is set and no bearer supplied", async () => { process.env[TOKEN_ENV] = "integration-secret"; - const s = await listen(okRun); + const s = await listen(okRun, null); try { const res = await fetch(`${s.url}/run`, { method: "POST", @@ -113,7 +117,7 @@ describe("server integration — in-process server with fake engine", () => { it("POST /run accepts the correct bearer token", async () => { process.env[TOKEN_ENV] = "integration-secret"; - const s = await listen(okRun); + const s = await listen(okRun, null); try { const res = await fetch(`${s.url}/run`, { method: "POST", @@ -161,7 +165,7 @@ describe("session record persist — skipped when Redis unavailable", () => { // The alive watchdog POSTs to the API; without a live API server the fetch // will fail, but the module must swallow failures and never throw. const { startAliveWatchdog } = await import("../../src/sessions/alive.ts"); - const watchdog = startAliveWatchdog("integ-sess", "integ-turn", "tok"); + const watchdog = await startAliveWatchdog("integ-sess", "integ-turn", "tok"); await assert.doesNotReject(() => watchdog.release()); }); diff --git a/services/runner/tests/unit/client-tools.test.ts b/services/runner/tests/unit/client-tools.test.ts index 7902c47d88..b4af378b5f 100644 --- a/services/runner/tests/unit/client-tools.test.ts +++ b/services/runner/tests/unit/client-tools.test.ts @@ -18,7 +18,6 @@ import { extractClientToolOutputs, } from "../../src/responder.ts"; import type { ClientToolRelayRequest } from "../../src/tools/client-tool-relay.ts"; -import { PendingApprovalLatch } from "../../src/permission-plan.ts"; import { buildClientToolRelay, createToolCallCorrelationIndex, @@ -47,7 +46,7 @@ function responderReturning(verdict: ClientToolVerdict): Responder { }; } -/** A seam harness: fake pause controller + latch + captured events/interactions. */ +/** A seam harness: fake pause controller + captured events/interactions. */ function seam(verdict: ClientToolVerdict, opts: { index?: boolean } = {}) { const events: AgentEvent[] = []; const pausedToolCalls: string[] = []; @@ -57,7 +56,6 @@ function seam(verdict: ClientToolVerdict, opts: { index?: boolean } = {}) { const relay = buildClientToolRelay({ responder: responderReturning(verdict), run: { emitEvent: (e) => events.push(e) }, - latch: new PendingApprovalLatch(), pause: { markPausedToolCall: (id) => pausedToolCalls.push(id), pause: () => { @@ -327,7 +325,6 @@ describe("buildClientToolRelay", () => { }, }, run: { emitEvent: () => {} }, - latch: new PendingApprovalLatch(), pause: { markPausedToolCall: () => {}, pause: () => {} }, recordPendingInteraction: () => {}, }); @@ -393,7 +390,6 @@ describe("buildClientToolRelay with the real responder (history-driven)", () => const relay = buildClientToolRelay({ responder, run: { emitEvent: (e) => events.push(e) }, - latch: new PendingApprovalLatch(), pause: { markPausedToolCall: (id) => paused.push(id), pause: () => {} }, recordPendingInteraction: () => {}, }); diff --git a/services/runner/tests/unit/pending-approval-pause.test.ts b/services/runner/tests/unit/pending-approval-pause.test.ts index ca70f9a14b..d0290286ce 100644 --- a/services/runner/tests/unit/pending-approval-pause.test.ts +++ b/services/runner/tests/unit/pending-approval-pause.test.ts @@ -32,6 +32,25 @@ describe("PendingApprovalPauseController", () => { assert.deepEqual(queuedUpdates, ["session update"]); }); + it("re-arms the bounded drain when a gate is classified after the first tick", async () => { + const pause = new PendingApprovalPauseController(() => {}); + pause.markPausedToolCall("tool-first"); + pause.pause(); + await Promise.resolve(); + await Promise.resolve(); + + let lateGateClassified = false; + setImmediate(() => { + pause.markPausedToolCall("tool-late"); + lateGateClassified = true; + }); + + await pause.waitForEventDrain(); + + assert.equal(lateGateClassified, true); + assert.equal(pause.isPausedToolCall("tool-late"), true); + }); + it("finishes the event drain when managed cancellation rejects", async () => { const pause = new PendingApprovalPauseController(() => Promise.reject(new Error("session already gone")), @@ -53,4 +72,18 @@ describe("PendingApprovalPauseController", () => { await assert.doesNotReject(pause.signal); await assert.doesNotReject(pause.waitForEventDrain()); }); + + it("tracks allowed execution ids independently from paused gates", () => { + const pause = new PendingApprovalPauseController(() => {}); + + assert.equal(pause.isAllowedExecution(undefined), false); + assert.equal(pause.isAllowedExecution("tool-1"), false); + + pause.markAllowedExecution("tool-1"); + pause.markPausedToolCall("tool-2"); + + assert.equal(pause.isAllowedExecution("tool-1"), true); + assert.equal(pause.isPausedToolCall("tool-1"), false); + assert.equal(pause.isAllowedExecution("tool-2"), false); + }); }); diff --git a/services/runner/tests/unit/permission-plan.test.ts b/services/runner/tests/unit/permission-plan.test.ts index e327a606a6..4e193ac3aa 100644 --- a/services/runner/tests/unit/permission-plan.test.ts +++ b/services/runner/tests/unit/permission-plan.test.ts @@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, it } from "vitest"; import assert from "node:assert/strict"; import { - PendingApprovalLatch, decide, effectivePermission, permissionsFromRequest, @@ -291,17 +290,6 @@ describe("permissionsFromRequest", () => { }); }); -describe("PendingApprovalLatch", () => { - it("lets only the first caller acquire", () => { - const latch = new PendingApprovalLatch(); - assert.equal(latch.held, false); - assert.equal(latch.tryAcquire(), true); - assert.equal(latch.held, true); - assert.equal(latch.tryAcquire(), false); - assert.equal(latch.held, true); - }); -}); - describe("stored decisions", () => { it("consults stored decisions only under ask", () => { const stored: StoredPermissionDecisions = { diff --git a/services/runner/tests/unit/responder.test.ts b/services/runner/tests/unit/responder.test.ts index 83a75536e4..0d43735cc1 100644 --- a/services/runner/tests/unit/responder.test.ts +++ b/services/runner/tests/unit/responder.test.ts @@ -7,6 +7,7 @@ import { describe, it } from "vitest"; import assert from "node:assert/strict"; import { + APPROVED_EXECUTION_RESULT_UNKNOWN, createSandboxAgentOtel, TOOL_NOT_EXECUTED_PAUSED, } from "../../src/tracing/otel.ts"; @@ -451,10 +452,10 @@ describe("extractApprovalDecisions", () => { const decisions = extractApprovalDecisions(request); assert.deepEqual( decisions.get(approvedCallKey("edit", { path: "a.txt" })!), - ["allow"], + [{ decision: "allow" }], ); assert.deepEqual(decisions.get(approvedCallKey("bash", { cmd: "ls" })!), [ - "deny", + { decision: "deny" }, ]); assert.equal(decisions.has("edit"), false); assert.equal(decisions.has("tc-1"), false); @@ -501,12 +502,15 @@ describe("extractApprovalDecisions", () => { const key = approvedCallKey("edit", { path: "a.txt" })!; const decisions = extractApprovalDecisions(request); - assert.deepEqual(decisions.get(key), ["allow", "allow"]); + assert.deepEqual(decisions.get(key), [ + { decision: "allow" }, + { decision: "allow" }, + ]); const stored = new ConversationDecisions(decisions); const duplicateGate = gate({ toolName: "edit", args: { path: "a.txt" } }); - assert.equal(stored.take(duplicateGate), "allow"); - assert.equal(stored.take(duplicateGate), "allow"); + assert.deepEqual(stored.take(duplicateGate), { decision: "allow" }); + assert.deepEqual(stored.take(duplicateGate), { decision: "allow" }); assert.equal(stored.take(duplicateGate), undefined); }); @@ -540,13 +544,13 @@ describe("extractApprovalDecisions", () => { }; const stored = new ConversationDecisions(extractApprovalDecisions(request)); - assert.equal( + assert.deepEqual( stored.take({ executor: "harness", toolName: "mcp__agenta-tools__commit_revision", args: { workflow_revision: JSON.stringify(workflowRevision) }, }), - "allow", + { decision: "allow" }, ); }); @@ -672,7 +676,7 @@ describe("client-tool output store (separate from approvals)", () => { // ...and lives only in the approval store. const decisions = extractApprovalDecisions(request); assert.deepEqual(decisions.get(approvedCallKey("edit", { path: "a" })!), [ - "allow", + { decision: "allow" }, ]); }); @@ -701,6 +705,14 @@ describe("client-tool output store (separate from approvals)", () => { output: TOOL_NOT_EXECUTED_PAUSED, isError: true, }, + { + type: "tool_result", + toolCallId: "c-linear", + toolName: "request_connection", + input: { integration: "linear" }, + output: APPROVED_EXECUTION_RESULT_UNKNOWN, + isError: true, + }, ], }, ], @@ -719,7 +731,13 @@ describe("client-tool output store (separate from approvals)", () => { ), false, ); - // So the model's retry of slack re-parks (a fresh widget) instead of resolving as fulfilled. + assert.equal( + outputs.has( + approvedCallKey("request_connection", { integration: "linear" })!, + ), + false, + ); + // So the model retry of slack re-parks (a fresh widget) instead of resolving as fulfilled. const responder = new ApprovalResponder( plan("ask"), new ConversationDecisions(extractApprovalDecisions(request), outputs), diff --git a/services/runner/tests/unit/sandbox-agent-acp-interactions.test.ts b/services/runner/tests/unit/sandbox-agent-acp-interactions.test.ts index 07e1ae8e07..663d3530d5 100644 --- a/services/runner/tests/unit/sandbox-agent-acp-interactions.test.ts +++ b/services/runner/tests/unit/sandbox-agent-acp-interactions.test.ts @@ -6,15 +6,19 @@ import { describe, it } from "vitest"; import assert from "node:assert/strict"; -import type { AgentEvent, ResolvedToolSpec } from "../../src/protocol.ts"; +import type { + AgentEvent, + AgentRunRequest, + ResolvedToolSpec, +} from "../../src/protocol.ts"; import { toolSpecsByName } from "../../src/tools/public-spec.ts"; import type { ClientToolVerdict, Responder } from "../../src/responder.ts"; import { ApprovalResponder, ConversationDecisions, + extractApprovalDecisions, } from "../../src/responder.ts"; import type { PermissionPlan, Verdict } from "../../src/permission-plan.ts"; -import { PendingApprovalLatch } from "../../src/permission-plan.ts"; import { attachPermissionResponder, type PiToolSpecMeta, @@ -96,7 +100,6 @@ describe("attachPermissionResponder", () => { session, run: { emitEvent: (event) => events.push(event) }, responder: fakeResponder({ kind: "allow" }, undefined, seen), - latch: new PendingApprovalLatch(), }); emit({ id: "perm-1", @@ -124,7 +127,6 @@ describe("attachPermissionResponder", () => { session, run: { emitEvent: (event) => events.push(event) }, responder: fakeResponder({ kind: "deny" }), - latch: new PendingApprovalLatch(), }); emit({ id: "perm-1", availableReplies: ["once", "reject"] }); await flushPromises(); @@ -148,7 +150,6 @@ describe("attachPermissionResponder", () => { markToolCallDenied: (id) => denied.push(id), }, responder: fakeResponder({ kind: "deny" }), - latch: new PendingApprovalLatch(), }); emit({ id: "perm-1", @@ -174,7 +175,6 @@ describe("attachPermissionResponder", () => { markToolCallDenied: (id) => denied.push(id), }, responder: fakeResponder({ kind: "allow" }), - latch: new PendingApprovalLatch(), }); emit({ id: "perm-1", @@ -205,7 +205,6 @@ describe("attachPermissionResponder", () => { session, run: { emitEvent: (event) => events.push(event) }, responder: fakeResponder({ kind: "pendingApproval" }), - latch: new PendingApprovalLatch(), onPause: () => { pauses += 1; }, @@ -256,27 +255,41 @@ describe("attachPermissionResponder", () => { ]); }); - it("two concurrent pending gates emit and pause only once through the latch", async () => { + it("two concurrent pending gates each emit their own card and pause the turn once", async () => { const { session, emit } = makeSession(); const events: AgentEvent[] = []; + const gates: string[] = []; let pauses = 0; attachPermissionResponder({ session, run: { emitEvent: (event) => events.push(event) }, responder: fakeResponder({ kind: "pendingApproval" }), - latch: new PendingApprovalLatch(), onPause: () => { pauses += 1; }, + onUserApprovalGate: (info) => { + gates.push(info.toolCallId); + }, }); emit({ id: "perm-1", toolCall: { toolCallId: "tool-1", name: "edit" } }); emit({ id: "perm-2", toolCall: { toolCallId: "tool-2", name: "bash" } }); await flushPromises(); - assert.equal(events.length, 1); - assert.equal((events[0] as any).id, "perm-1"); - assert.equal(pauses, 1); + // No latch: each gate emits its own interaction_request card, keyed by its own tool-call id. + assert.equal(events.length, 2); + assert.deepEqual( + events.map((e) => (e as any).id), + ["perm-1", "perm-2"], + ); + assert.deepEqual( + events.map((e) => (e as any).payload.toolCallId), + ["tool-1", "tool-2"], + ); + // onPause fires once per gate at this layer; the shared PendingApprovalPauseController dedupes + // to a single turn-end (asserted in pending-approval-pause.test.ts). Both gates signalled. + assert.equal(pauses, 2); + assert.deepEqual(gates, ["tool-1", "tool-2"]); }); it("reply rejection pauses and does not resolve the interaction", async () => { @@ -290,7 +303,6 @@ describe("attachPermissionResponder", () => { session, run: { emitEvent: () => {} }, responder: fakeResponder({ kind: "allow" }), - latch: new PendingApprovalLatch(), onPause: () => { pauses += 1; }, @@ -317,7 +329,6 @@ describe("attachPermissionResponder", () => { session, run: { emitEvent: (event) => events.push(event) }, responder: fakeResponder({ kind: "allow" }), - latch: new PendingApprovalLatch(), onPause: () => { pauses += 1; }, @@ -353,7 +364,6 @@ describe("attachPermissionResponder", () => { session, run: { emitEvent: (event) => events.push(event) }, responder: fakeResponder({ kind: "deny" }, { kind: "pendingApproval" }), - latch: new PendingApprovalLatch(), toolSpecsByName: specsByName([CLIENT_SPEC]), onPause: () => { pauses += 1; @@ -424,7 +434,6 @@ describe("attachPermissionResponder", () => { { kind: "deny" }, { kind: "fulfilled", output: { connected: true } }, ), - latch: new PendingApprovalLatch(), toolSpecsByName: specsByName([CLIENT_SPEC]), }); emit({ @@ -442,6 +451,168 @@ describe("attachPermissionResponder", () => { assert.deepEqual(events, []); }); + it("resolves the original interaction token after a cold stored-decision reply", async () => { + const decisions = extractApprovalDecisions({ + messages: [ + { + role: "assistant", + content: [ + { + type: "tool_call", + toolCallId: "tool-original", + toolName: "edit", + input: { path: "a.txt" }, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool_result", + toolCallId: "tool-original", + output: { + approved: true, + interactionToken: "interaction-original", + }, + }, + ], + }, + ], + } as AgentRunRequest); + const responder = new ApprovalResponder( + permissionPlan("ask"), + new ConversationDecisions(decisions), + ); + const replies: Array<{ id: string; reply: string }> = []; + const resolved: Array<{ + token: string; + verdict?: { approved: boolean; toolCallId: string }; + }> = []; + const { session, emit } = makeSession(async (id, reply) => { + replies.push({ id, reply }); + }); + + attachPermissionResponder({ + session, + run: { emitEvent: () => {} }, + responder, + onResolveInteraction: (token, verdict) => { + resolved.push({ token, verdict }); + }, + }); + emit({ + id: "permission-cold", + availableReplies: ["once", "reject"], + toolCall: { + toolCallId: "tool-cold", + name: "edit", + rawInput: { path: "a.txt" }, + }, + }); + await flushPromises(); + + assert.deepEqual(replies, [{ id: "permission-cold", reply: "once" }]); + assert.deepEqual(resolved, [ + { + token: "interaction-original", + verdict: { approved: true, toolCallId: "tool-cold" }, + }, + ]); + }); + + it("passes the approval verdict when resolving a previously created gate", async () => { + const { session, emit } = makeSession(); + let permissionCalls = 0; + const resolved: Array<{ + token: string; + verdict?: { approved: boolean; toolCallId: string }; + }> = []; + + attachPermissionResponder({ + session, + run: { emitEvent: () => {} }, + responder: { + async onPermission() { + permissionCalls += 1; + return permissionCalls === 1 + ? ({ kind: "pendingApproval" } as const) + : ({ kind: "deny" } as const); + }, + async onClientTool() { + return { kind: "deny" } as const; + }, + }, + onResolveInteraction: (token, verdict) => { + resolved.push({ token, verdict }); + }, + }); + const request = { + id: "approval-1", + availableReplies: ["once", "reject"], + toolCall: { toolCallId: "tool-1", name: "bash" }, + }; + emit(request); + await flushPromises(); + emit(request); + await flushPromises(); + + assert.deepEqual(resolved, [ + { + token: "approval-1", + verdict: { approved: false, toolCallId: "tool-1" }, + }, + ]); + }); + + it("resolves a client-tool row without an approval verdict", async () => { + const replies: Array<{ id: string; reply: string }> = []; + const { session, emit } = makeSession(async (id, reply) => { + replies.push({ id, reply }); + }); + let clientCalls = 0; + const resolved: Array<{ + token: string; + verdict?: { approved: boolean; toolCallId: string }; + }> = []; + + attachPermissionResponder({ + session, + run: { emitEvent: () => {} }, + responder: { + async onPermission() { + return { kind: "deny" } as const; + }, + async onClientTool() { + clientCalls += 1; + return clientCalls === 1 + ? ({ kind: "pendingApproval" } as const) + : ({ kind: "fulfilled", output: { connected: true } } as const); + }, + }, + toolSpecsByName: specsByName([CLIENT_SPEC]), + onResolveInteraction: (token, verdict) => { + resolved.push({ token, verdict }); + }, + }); + const request = { + id: "client-1", + availableReplies: ["once", "reject"], + toolCall: { + toolCallId: "tool-client", + name: "request_connection", + input: { integration: "slack" }, + }, + }; + emit(request); + await flushPromises(); + emit(request); + await flushPromises(); + + assert.deepEqual(replies, [{ id: "client-1", reply: "once" }]); + assert.deepEqual(resolved, [{ token: "client-1", verdict: undefined }]); + }); + it("onResolveInteraction never fires for an auto-allowed gate (no durable row exists)", async () => { // Only a PAUSED gate creates a durable interaction row. An auto-allowed gate replies to // the harness without ever creating one, so resolving its id would 404 against the @@ -462,7 +633,6 @@ describe("attachPermissionResponder", () => { session, run: { emitEvent: () => {} }, responder: fakeResponder({ kind: "allow" }), - latch: new PendingApprovalLatch(), onResolveInteraction: (token) => { resolved.push(token); }, @@ -491,7 +661,6 @@ describe("attachPermissionResponder", () => { session, run: { emitEvent: () => {} }, responder: fakeResponder({ kind: "pendingApproval" }), - latch: new PendingApprovalLatch(), onCreateInteraction: (token) => { created.push(token); }, @@ -531,7 +700,6 @@ describe("attachPermissionResponder", () => { ], }, responder: fakeResponder({ kind: "allow" }, undefined, seen), - latch: new PendingApprovalLatch(), }); emit({ id: "perm-1", availableReplies: ["once", "reject"], toolCall }); await flushPromises(); @@ -557,7 +725,6 @@ describe("attachPermissionResponder", () => { session, run: { emitEvent: () => {} }, responder: fakeResponder({ kind: "pendingApproval" }, undefined, seen), - latch: new PendingApprovalLatch(), toolSpecsByName: specsByName([ { name: "commit_revision", permission: "ask", readOnly: false }, ]), @@ -592,7 +759,6 @@ describe("attachPermissionResponder", () => { session, run: { emitEvent: () => {} }, responder: fakeResponder({ kind: "pendingApproval" }, undefined, seen), - latch: new PendingApprovalLatch(), toolSpecsByName: specsByName([ { name: "delete_everything", permission: "deny", readOnly: false }, ]), @@ -627,7 +793,6 @@ describe("attachPermissionResponder", () => { session, run: { emitEvent: () => {} }, responder: fakeResponder({ kind: "pendingApproval" }, undefined, seen), - latch: new PendingApprovalLatch(), toolSpecsByName: specsByName([ { name: "commit_revision", permission: "ask" }, ]), @@ -657,7 +822,6 @@ describe("attachPermissionResponder", () => { session, run: { emitEvent: () => {} }, responder: fakeResponder({ kind: "pendingApproval" }, undefined, seen), - latch: new PendingApprovalLatch(), serverPermissions: new Map([["github", "deny"]]), }); emit({ @@ -738,7 +902,6 @@ describe("attachPermissionResponder: Pi dialog gate", () => { session, run: { emitEvent: (event) => events.push(event) }, responder: fakeResponder({ kind: "pendingApproval" }), - latch: new PendingApprovalLatch(), piToolSpecsByName: piSpecs(), onPausedToolCall: (id) => pausedToolCalls.push(id), onUserApprovalGate: (info) => gates.push(info), @@ -779,7 +942,6 @@ describe("attachPermissionResponder: Pi dialog gate", () => { run: { emitEvent: (event) => events.push(event) }, // A default-allow responder: if the malformed request fell through it would ALLOW. responder: fakeResponder({ kind: "allow" }), - latch: new PendingApprovalLatch(), piToolSpecsByName: piSpecs(), onPause: () => { pauses += 1; @@ -815,7 +977,6 @@ describe("attachPermissionResponder: Pi dialog gate", () => { session, run: { emitEvent: () => {} }, responder: fakeResponder({ kind: "pendingApproval" }, undefined, seen), - latch: new PendingApprovalLatch(), piToolSpecsByName: piSpecs(), }); emit( @@ -851,7 +1012,6 @@ describe("attachPermissionResponder: Pi dialog gate", () => { session, run: { emitEvent: () => {} }, responder, - latch: new PendingApprovalLatch(), piToolSpecsByName, }); emit( @@ -892,7 +1052,6 @@ describe("attachPermissionResponder: Pi dialog gate", () => { session, run: { emitEvent: () => {} }, responder, - latch: new PendingApprovalLatch(), piToolSpecsByName: piSpecs(), onPause: () => { pauses += 1; @@ -929,7 +1088,6 @@ describe("attachPermissionResponder: Pi dialog gate", () => { session, run: { emitEvent: () => {} }, responder, - latch: new PendingApprovalLatch(), piToolSpecsByName: piSpecs(), onPause: () => { pauses += 1; @@ -965,7 +1123,6 @@ describe("attachPermissionResponder: Pi dialog gate", () => { session, run: { emitEvent: (event) => events.push(event) }, responder, - latch: new PendingApprovalLatch(), piToolSpecsByName: piSpecs(), onPause: () => { pauses += 1; @@ -1000,7 +1157,6 @@ describe("attachPermissionResponder: Pi dialog gate", () => { session, run: { emitEvent: (event) => events.push(event) }, responder, - latch: new PendingApprovalLatch(), piToolSpecsByName: piSpecs([["park_probe", {}]]), }); emit( @@ -1029,7 +1185,6 @@ describe("attachPermissionResponder: Pi dialog gate", () => { session, run: { emitEvent: (event) => events.push(event) }, responder: fakeResponder({ kind: "pendingApproval" }), - latch: new PendingApprovalLatch(), piToolSpecsByName: piSpecs([ [ "test_run", @@ -1073,7 +1228,6 @@ describe("attachPermissionResponder: Pi dialog gate", () => { session, run: { emitEvent: () => {} }, responder, - latch: new PendingApprovalLatch(), piToolSpecsByName: piSpecs([ [ "author_allow", @@ -1117,7 +1271,6 @@ describe("attachPermissionResponder: Pi dialog gate", () => { session, run: { emitEvent: () => {} }, responder, - latch: new PendingApprovalLatch(), piToolSpecsByName: piSpecs([["author_deny", { permission: "deny" }]]), onPiGateAllowed: (info) => allowed.push(info), }); @@ -1163,7 +1316,6 @@ describe("attachPermissionResponder: Pi dialog gate", () => { session, run: { emitEvent: (event) => events.push(event) }, responder: fakeResponder({ kind: "pendingApproval" }), - latch: new PendingApprovalLatch(), // piToolSpecsByName intentionally absent (a Claude run). onPause: () => { pauses += 1; diff --git a/services/runner/tests/unit/sandbox-agent-orchestration.test.ts b/services/runner/tests/unit/sandbox-agent-orchestration.test.ts index b7cce0d9ab..83ad9702b7 100644 --- a/services/runner/tests/unit/sandbox-agent-orchestration.test.ts +++ b/services/runner/tests/unit/sandbox-agent-orchestration.test.ts @@ -15,6 +15,7 @@ import { TOOL_NOT_EXECUTED_PAUSED, } from "../../src/tracing/otel.ts"; import { PendingApprovalPauseController } from "../../src/engines/sandbox_agent/pause.ts"; +import { shouldSuppressPausedToolCallUpdate } from "../../src/engines/sandbox_agent/runtime-policy.ts"; import { mountStorage } from "../../src/engines/sandbox_agent/mount.ts"; import { buildPiGateEnvelope } from "../../src/engines/sandbox_agent/pi-gate-envelope.ts"; import type { PermissionDecision } from "../../src/responder.ts"; @@ -219,6 +220,9 @@ function fakeHarness(options: FakeOptions = {}) { _isExcluded: (id: string) => boolean, _message: string, ) {}, + openToolCallIds() { + return []; + }, traceId() { return "trace-1"; }, @@ -314,6 +318,24 @@ describe("PendingApprovalPauseController", () => { assert.equal(pause.isPausedToolCall("tool-1"), true); assert.equal(pause.isPausedToolCall("tool-2"), false); }); + + it("passes through a failed frame for an allowed execution during a pause", () => { + const pause = new PendingApprovalPauseController(() => {}); + pause.markAllowedExecution("tool-allowed"); + pause.pause(); + + assert.equal( + shouldSuppressPausedToolCallUpdate( + { + sessionUpdate: "tool_call_update", + toolCallId: "tool-allowed", + status: "failed", + }, + pause, + ), + false, + ); + }); }); describe("runSandboxAgent orchestration", () => { @@ -1348,6 +1370,9 @@ describe("runSandboxAgent orchestration", () => { return []; }, settleOpenToolCalls() {}, + openToolCallIds() { + return []; + }, traceId() { return "trace-1"; }, @@ -1750,7 +1775,7 @@ describe("runSandboxAgent default ApprovalResponder wiring", () => { ); }); - it("settles a latch-loser sibling when read-only permission requests race", async () => { + it("emits a card for EACH of two racing ask gates and force-settles neither (cold-path plural approvals)", async () => { const readOnlySpec = (name: string) => ({ name, kind: "callback", @@ -1828,31 +1853,29 @@ describe("runSandboxAgent default ApprovalResponder wiring", () => { if (!result.ok) return; assert.equal(result.stopReason, "paused"); + // No latch: BOTH gated tool calls emit their own approval card in the one paused turn, each + // keyed by its own tool-call id. This is exactly issue #5373's fix — the human now sees every + // card at once instead of one, with the rest re-asked across later turns. const interactions = result.events?.filter( (event) => event.type === "interaction_request", ); - assert.equal(interactions?.length, 1); - assert.equal((interactions?.[0] as any).payload?.toolCallId, "tool-a"); + assert.equal(interactions?.length, 2); + assert.deepEqual( + interactions?.map((e) => (e as any).payload?.toolCallId).sort(), + ["tool-a", "tool-b"], + ); - const toolResults = result.events - ?.filter((event) => event.type === "tool_result") - .map((event) => ({ - id: (event as any).id, - output: (event as any).output, - isError: (event as any).isError, - })); - assert.deepEqual(toolResults, [ - { - id: "tool-b", - output: TOOL_NOT_EXECUTED_PAUSED, - isError: true, - }, - ]); + // Neither gate is force-settled as TOOL_NOT_EXECUTED_PAUSED: both are held open for their own + // approval, so no orphaned paused tool_result is emitted. + const toolResults = result.events?.filter( + (event) => event.type === "tool_result", + ); + assert.deepEqual(toolResults, []); }); it("settles a sibling whose announcement arrives AFTER the pause (teardown race)", async () => { // The live incident shape: the sibling's `tool_call` announcement rides the ACP event - // stream and can arrive at the runner AFTER the gate won the latch and the pause-time + // stream and can arrive at the runner AFTER the gate paused the turn and the pause-time // sweep already ran. The event-handler re-sweep must settle it deterministically. const { deps } = fakeHarness({ promptEvents: [ @@ -2099,6 +2122,105 @@ describe("runSandboxAgent default ApprovalResponder wiring", () => { ); }); + it("classifies pause-time completed frames by effective permission", async () => { + const { deps } = fakeHarness({ + promptEvents: [ + { + payload: { + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-ask", + title: "ask_sibling", + }, + }, + }, + { + payload: { + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-allow", + title: "allow_sibling", + }, + }, + }, + { + payload: { + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-gate", + title: "pause_gate", + }, + }, + }, + ], + emitPermission: true, + permissionToolCallId: "tool-gate", + permissionToolName: "pause_gate", + postPermissionEvents: [ + { + payload: { + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-ask", + status: "completed", + content: "cancellation closure", + }, + }, + }, + { + payload: { + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-allow", + status: "completed", + content: "real allow result", + }, + }, + }, + ], + hangPrompt: true, + }); + delete deps.responderFactory; + deps.createOtel = createSandboxAgentOtel as any; + + const result = await runSandboxAgent( + { + harness: "claude", + permissions: { default: "ask" }, + customTools: [ + { name: "ask_sibling", permission: "ask" }, + { name: "allow_sibling", permission: "allow" }, + { name: "pause_gate", permission: "ask" }, + ], + messages: [{ role: "user", content: "run the siblings" }], + } as AgentRunRequest, + undefined, + undefined, + deps, + ); + + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.stopReason, "paused"); + const toolResults = new Map( + result.events + ?.filter((event) => event.type === "tool_result") + .map((event) => [(event as any).id, event]), + ); + assert.deepEqual(toolResults.get("tool-ask"), { + type: "tool_result", + id: "tool-ask", + output: TOOL_NOT_EXECUTED_PAUSED, + isError: true, + }); + assert.deepEqual(toolResults.get("tool-allow"), { + type: "tool_result", + id: "tool-allow", + output: "real allow result", + isError: false, + }); + }); + it("park ENDS the turn even when the prompt hangs: terminal stopReason 'paused', no harness reply (F-040)", async () => { // The real regression: Claude does NOT end a turn on an unanswered gate, so without the // park->cancel path `session.prompt()` blocks forever and the run never returns. With diff --git a/services/runner/tests/unit/sandbox-lifecycle.test.ts b/services/runner/tests/unit/sandbox-lifecycle.test.ts index ab9799ef73..4d2add811d 100644 --- a/services/runner/tests/unit/sandbox-lifecycle.test.ts +++ b/services/runner/tests/unit/sandbox-lifecycle.test.ts @@ -45,12 +45,17 @@ function fakeSandbox(sandboxId: string | undefined, opts: FakeOpts = {}) { paused: 0, destroyed: 0, disposed: 0, - wrote: [] as Array<{ sandboxId: string; turnIndex: number }>, - cleared: [] as Array<{ sessionId: string; turnIndex: number }>, + appended: [] as Array<{ + sessionId: string; + harness: string; + turnIndex: number; + sandboxId: string | undefined; + }>, logs: [] as string[], }; const session = { id: "session-1", + agentSessionId: "agent-fake-1", onEvent() {}, onPermissionRequest() {}, async prompt() { @@ -106,7 +111,14 @@ function fakeSandbox(sandboxId: string | undefined, opts: FakeOpts = {}) { createPersist: () => ({}) as any, sessionContinuityStore: continuityStore, hydrateHarnessSessionFromDurable: async () => {}, - syncHarnessSessionDurable: async () => {}, + appendSessionTurn: async (sessionId, harness, turnIndex, turn) => { + calls.appended.push({ + sessionId, + harness, + turnIndex, + sandboxId: turn.sandboxId, + }); + }, startSandboxAgent: (async (startOpts: any) => { calls.starts.push({ sandboxId: startOpts.sandboxId }); // The dead rung: Daytona already archived/deleted the parked sandbox, so reconnect @@ -164,17 +176,6 @@ function fakeSandbox(sandboxId: string | undefined, opts: FakeOpts = {}) { // Lifecycle seam under test: readStoredSandboxPointer: async () => sandboxId ? { sandboxId } : undefined, - clearSandboxPointer: async (sessionId, turnIndex) => { - calls.cleared.push({ sessionId, turnIndex }); - return "applied"; - }, - writeSandboxPointer: async (_sessionId, pointer) => { - calls.wrote.push({ - sandboxId: pointer.sandboxId, - turnIndex: pointer.turnIndex, - }); - return "applied"; - }, }; return { calls, deps }; } @@ -183,8 +184,9 @@ const daytonaRequest: AgentRunRequest = { harness: "claude", sandbox: "daytona", sessionId: "sess-1", + streamId: "stream-1", messages: [{ role: "user", content: "hello" }], - // A session-owned run always carries the invoke credential; the read/write helpers need it. + // A session-owned run always carries the invoke credential; the read/append helpers need it. telemetry: { exporters: { otlp: { headers: { authorization: "ApiKey abc" } } }, } as any, @@ -235,7 +237,7 @@ describe("remote sandbox reconnect ladder", () => { assert.equal(calls.starts[0].sandboxId, undefined); }); - it("falls through to a fresh create without clearing on a transient reconnect failure", async () => { + it("falls through to a fresh create on a transient reconnect failure", async () => { const { calls, deps } = fakeSandbox("sbx-gone", { reconnectThrows: true }); const result = await runSandboxAgent( daytonaRequest, @@ -259,10 +261,9 @@ describe("remote sandbox reconnect ladder", () => { undefined, "the retry carries no id", ); - assert.deepEqual(calls.cleared, []); }); - it("clears the pointer before a fresh create on a terminal reconnect failure", async () => { + it("falls through to a fresh create on a terminal reconnect failure, no pointer clear needed", async () => { const { calls, deps } = fakeSandbox("sbx-gone", { reconnectTerminalState: "not-found", }); @@ -276,62 +277,37 @@ describe("remote sandbox reconnect ladder", () => { assert.equal(result.ok, true); assert.equal(calls.starts.length, 2); - assert.deepEqual(calls.cleared, [{ sessionId: "sess-1", turnIndex: 0 }]); - }); - - it("hydrates the turn index before the guarded clear (post-restart token)", async () => { - const { calls, deps } = fakeSandbox("sbx-gone", { - reconnectTerminalState: "not-found", - }); - // A restarted runner has an empty in-memory store; the durable row knows turn 5. - deps.hydrateHarnessSessionFromDurable = async ( - sessionId, - _harness, - store, - ) => { - store.restoreLatestTurn(sessionId, 5); - }; - - const result = await runSandboxAgent( - daytonaRequest, - undefined, - undefined, - deps, - ); - - assert.equal(result.ok, true); - assert.deepEqual( - calls.cleared, - [{ sessionId: "sess-1", turnIndex: 6 }], - "the clear must carry the hydrated index, not the cold store's 0", - ); + // No explicit clear call exists anymore: the fresh sandbox this turn creates gets its own + // turn row at completion, whose higher turn_index naturally supersedes the dead pointer on + // the next `latest_turn` read. + assert.equal(calls.appended.length, 1); }); - it("does not write a pointer for a local run", async () => { + it("does not append a turn for a local run without a streamId", async () => { const { calls, deps } = fakeSandbox(undefined); const result = await runSandboxAgent( - { ...daytonaRequest, sandbox: "local" }, + { ...daytonaRequest, sandbox: "local", streamId: undefined }, undefined, undefined, deps, ); assert.equal(result.ok, true); - assert.deepEqual( - calls.wrote, - [], - "a local run must not overwrite a conversation's remote pointer", - ); + assert.deepEqual(calls.appended, []); }); - it("writes the live sandbox id forward for the next turn", async () => { + it("appends the live sandbox id forward on the completed turn's row", async () => { const { calls, deps } = fakeSandbox("sbx-99"); await runSandboxAgent(daytonaRequest, undefined, undefined, deps); - assert.deepEqual(calls.wrote, [{ sandboxId: "sbx-99", turnIndex: 0 }]); + assert.equal(calls.appended.length, 1); + assert.equal(calls.appended[0].sessionId, "sess-1"); + assert.equal(calls.appended[0].harness, "claude"); + assert.equal(calls.appended[0].turnIndex, 0); + assert.equal(calls.appended[0].sandboxId, "sbx-99"); }); - it("writes the next turn index after durable continuity hydration", async () => { + it("appends at the next turn index after durable continuity hydration", async () => { const { calls, deps } = fakeSandbox("sbx-99"); deps.hydrateHarnessSessionFromDurable = async ( sessionId, @@ -343,7 +319,8 @@ describe("remote sandbox reconnect ladder", () => { await runSandboxAgent(daytonaRequest, undefined, undefined, deps); - assert.deepEqual(calls.wrote, [{ sandboxId: "sbx-99", turnIndex: 6 }]); + assert.equal(calls.appended.length, 1); + assert.equal(calls.appended[0].turnIndex, 6); }); it("trusts a stored pointer and reconnects by id without a compatibility check", async () => { @@ -362,27 +339,21 @@ describe("remote sandbox reconnect ladder", () => { ); }); - it("awaits a rejected pointer write and logs the outcome without failing", async () => { - const { calls, deps } = fakeSandbox(undefined); - let writeFinished = false; - deps.writeSandboxPointer = async () => { - await Promise.resolve(); - writeFinished = true; - return "rejected"; + it("never throws when the turn-append call fails", async () => { + const { deps } = fakeSandbox(undefined); + deps.appendSessionTurn = async () => { + throw new Error("network error"); }; + // appendSessionTurn is called fire-and-forget (`void`), so a rejection here must never + // surface as an unhandled rejection or fail the run. const result = await runSandboxAgent( daytonaRequest, undefined, undefined, deps, ); - assert.equal(result.ok, true); - assert.equal(writeFinished, true); - assert.ok( - calls.logs.some((message) => message.includes("pointer write rejected")), - ); }); }); diff --git a/services/runner/tests/unit/sandbox-reconnect.test.ts b/services/runner/tests/unit/sandbox-reconnect.test.ts index ab329882b5..d860402fba 100644 --- a/services/runner/tests/unit/sandbox-reconnect.test.ts +++ b/services/runner/tests/unit/sandbox-reconnect.test.ts @@ -1,22 +1,24 @@ /** - * Unit tests for the sandbox-id read/write helpers that back the remote reconnect ladder. - * Exercised through injected fetch/deps -- no real HTTP. + * Unit tests for the sandbox-id read helper that backs the remote reconnect ladder. The write + * side (the live sandbox id landing on the next turn's row) is covered by + * `session-continuity-durable.test.ts`'s `appendSessionTurn` tests. Exercised through injected + * fetch/deps -- no real HTTP. * * Run: pnpm exec vitest run tests/unit/sandbox-reconnect.test.ts */ import { describe, it } from "vitest"; import assert from "node:assert/strict"; -import { - clearSandboxPointer, - readStoredSandboxPointer, - writeSandboxPointer, -} from "../../src/engines/sandbox_agent/sandbox-reconnect.ts"; +import { readStoredSandboxPointer } from "../../src/engines/sandbox_agent/sandbox-reconnect.ts"; const SILENT = () => {}; function okResponse(body: unknown): Response { - return { ok: true, status: 200, json: async () => body } as unknown as Response; + return { + ok: true, + status: 200, + json: async () => body, + } as unknown as Response; } function errResponse(status: number): Response { @@ -24,144 +26,86 @@ function errResponse(status: number): Response { } describe("readStoredSandboxPointer", () => { - it("returns the stored pointer from the durable row", async () => { + it("returns the latest turn's sandbox_id", async () => { const pointer = await readStoredSandboxPointer("sess-1", { apiBase: "http://api:8000", authorization: "ApiKey abc", fetchImpl: (async () => okResponse({ - session_state: { - sandbox_id: "sbx-42", - }, + turns: [{ sandbox_id: "sbx-42", turn_index: 3 }], })) as unknown as typeof fetch, log: SILENT, }); assert.deepEqual(pointer, { sandboxId: "sbx-42" }); }); - it("returns undefined when the row has no sandbox_id", async () => { - const id = await readStoredSandboxPointer("sess-1", { + it("queries unscoped by harness (the pointer is session-wide, not per-harness)", async () => { + let body: Record | undefined; + await readStoredSandboxPointer("sess-1", { apiBase: "http://api:8000", authorization: "ApiKey abc", - fetchImpl: (async () => - okResponse({ session_state: { sandbox_id: null } })) as unknown as typeof fetch, + fetchImpl: (async (_url: string, init?: RequestInit) => { + body = JSON.parse(init!.body as string); + return okResponse({ turns: [] }); + }) as unknown as typeof fetch, log: SILENT, }); - assert.equal(id, undefined); + assert.deepEqual(body!["query"], { session_id: "sess-1" }); + assert.deepEqual(body!["windowing"], { limit: 1, order: "descending" }); }); - it("returns undefined on a non-2xx (degrades to a fresh create)", async () => { + it("returns undefined when no turn has a sandbox_id", async () => { const id = await readStoredSandboxPointer("sess-1", { apiBase: "http://api:8000", authorization: "ApiKey abc", - fetchImpl: (async () => errResponse(503)) as unknown as typeof fetch, + fetchImpl: (async () => + okResponse({ turns: [{ turn_index: 0 }] })) as unknown as typeof fetch, log: SILENT, }); assert.equal(id, undefined); }); - it("returns undefined when fetch throws, no throw out", async () => { + it("returns undefined when there are no turns yet", async () => { const id = await readStoredSandboxPointer("sess-1", { apiBase: "http://api:8000", authorization: "ApiKey abc", - fetchImpl: (async () => { - throw new Error("ECONNREFUSED"); - }) as unknown as typeof fetch, + fetchImpl: (async () => + okResponse({ turns: [] })) as unknown as typeof fetch, log: SILENT, }); assert.equal(id, undefined); }); - it("ignores an empty-string id", async () => { + it("returns undefined on a non-2xx (degrades to a fresh create)", async () => { const id = await readStoredSandboxPointer("sess-1", { apiBase: "http://api:8000", authorization: "ApiKey abc", - fetchImpl: (async () => - okResponse({ session_state: { sandbox_id: "" } })) as unknown as typeof fetch, + fetchImpl: (async () => errResponse(503)) as unknown as typeof fetch, log: SILENT, }); assert.equal(id, undefined); }); -}); -describe("clearSandboxPointer", () => { - it("PUTs null pointer fields with the guard token", async () => { - let body: Record | undefined; - const outcome = await clearSandboxPointer("sess-1", 7, { - apiBase: "http://api:8000", - authorization: "ApiKey abc", - fetchImpl: (async (_url: string, init?: RequestInit) => { - body = JSON.parse(init!.body as string); - return okResponse({ session_state: { sandbox_id: null } }); - }) as unknown as typeof fetch, - log: SILENT, - }); - - assert.deepEqual(body, { - sandbox_id: null, - sandbox_turn_index: 7, - }); - assert.equal(outcome, "applied"); - }); -}); - -describe("writeSandboxPointer", () => { - it("PUTs the sandbox pointer and guard token on the row", async () => { - let body: Record | undefined; - const outcome = await writeSandboxPointer("sess-1", { - sandboxId: "sbx-42", - turnIndex: 3, - }, { + it("returns undefined when fetch throws, no throw out", async () => { + const id = await readStoredSandboxPointer("sess-1", { apiBase: "http://api:8000", authorization: "ApiKey abc", - fetchImpl: (async (_url: string, init?: RequestInit) => { - body = JSON.parse(init!.body as string); - return okResponse({ session_state: { sandbox_id: "sbx-42" } }); + fetchImpl: (async () => { + throw new Error("ECONNREFUSED"); }) as unknown as typeof fetch, log: SILENT, }); - assert.deepEqual(body, { - sandbox_id: "sbx-42", - sandbox_turn_index: 3, - }); - assert.equal(outcome, "applied"); + assert.equal(id, undefined); }); - it("returns rejected when the response keeps another sandbox id", async () => { - const outcome = await writeSandboxPointer("sess-1", { - sandboxId: "sbx-stale", - turnIndex: 1, - }, { + it("ignores an empty-string id", async () => { + const id = await readStoredSandboxPointer("sess-1", { apiBase: "http://api:8000", authorization: "ApiKey abc", fetchImpl: (async () => - okResponse({ session_state: { sandbox_id: "sbx-current" } })) as unknown as typeof fetch, + okResponse({ turns: [{ sandbox_id: "" }] })) as unknown as typeof fetch, log: SILENT, }); - assert.equal(outcome, "rejected"); - }); - - it("never throws when the PUT fails", async () => { - await assert.doesNotReject(() => - writeSandboxPointer("sess-1", { sandboxId: "sbx-42", turnIndex: 0 }, { - apiBase: "http://api:8000", - authorization: "ApiKey abc", - fetchImpl: (async () => errResponse(503)) as unknown as typeof fetch, - log: SILENT, - }), - ); - }); - - it("never throws when fetch throws", async () => { - await assert.doesNotReject(() => - writeSandboxPointer("sess-1", { sandboxId: "sbx-42", turnIndex: 0 }, { - apiBase: "http://api:8000", - authorization: "ApiKey abc", - fetchImpl: (async () => { - throw new Error("ECONNREFUSED"); - }) as unknown as typeof fetch, - log: SILENT, - }), - ); + assert.equal(id, undefined); }); }); diff --git a/services/runner/tests/unit/server.test.ts b/services/runner/tests/unit/server.test.ts index 851740ae21..6b9d88abb5 100644 --- a/services/runner/tests/unit/server.test.ts +++ b/services/runner/tests/unit/server.test.ts @@ -43,8 +43,13 @@ const AUTH = { authorization: `Bearer ${TEST_TOKEN}` }; async function listen( run: RunAgent, + token: string | null = TEST_TOKEN, ): Promise<{ url: string; close: () => Promise }> { - if (!process.env[TOKEN_ENV]) process.env[TOKEN_ENV] = TEST_TOKEN; + // Force the configured token unconditionally (default TEST_TOKEN; `null` = leave the env + // as the test set it, for the tokenless-boot case). A loaded dev env (`load-env` before + // the suite) sets AGENTA_RUNNER_TOKEN=replace-me; a "set only if unset" guard would let + // that leak in and 401 every AUTH request. afterEach restores the pre-suite value. + if (token !== null) process.env[TOKEN_ENV] = token; const server = createAgentServer(run); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const { port } = server.address() as AddressInfo; @@ -142,8 +147,7 @@ describe("createAgentServer", () => { }); it("POST /run without the token returns 401 when a token is configured", async () => { - process.env[TOKEN_ENV] = "s3cret"; - const s = await listen(okRun); + const s = await listen(okRun, "s3cret"); try { const res = await fetch(`${s.url}/run`, { method: "POST", body: "{}" }); assert.equal(res.status, 401); @@ -156,8 +160,7 @@ describe("createAgentServer", () => { }); it("POST /run with a wrong token returns 401", async () => { - process.env[TOKEN_ENV] = "s3cret"; - const s = await listen(okRun); + const s = await listen(okRun, "s3cret"); try { const res = await fetch(`${s.url}/run`, { method: "POST", @@ -171,8 +174,7 @@ describe("createAgentServer", () => { }); it("POST /run accepts the matching token via Authorization: Bearer", async () => { - process.env[TOKEN_ENV] = "s3cret"; - const s = await listen(okRun); + const s = await listen(okRun, "s3cret"); try { const res = await fetch(`${s.url}/run`, { method: "POST", @@ -186,8 +188,7 @@ describe("createAgentServer", () => { }); it("POST /run accepts the matching token via X-Agenta-Runner-Token", async () => { - process.env[TOKEN_ENV] = "s3cret"; - const s = await listen(okRun); + const s = await listen(okRun, "s3cret"); try { const res = await fetch(`${s.url}/run`, { method: "POST", @@ -202,8 +203,7 @@ describe("createAgentServer", () => { it("GET /health is reachable without the token even when one is configured", async () => { // Health is for liveness probes and carries no secrets, so the token gate is on /run only. - process.env[TOKEN_ENV] = "s3cret"; - const s = await listen(okRun); + const s = await listen(okRun, "s3cret"); try { const res = await fetch(`${s.url}/health`); assert.equal(res.status, 200); diff --git a/services/runner/tests/unit/session-alive-interrupt.test.ts b/services/runner/tests/unit/session-alive-interrupt.test.ts new file mode 100644 index 0000000000..72bd2c0380 --- /dev/null +++ b/services/runner/tests/unit/session-alive-interrupt.test.ts @@ -0,0 +1,121 @@ +/** + * W7.4: a cancel/steer/kill against a session-owned run must reach the runner process. + * + * The API's heartbeat response carries `is_current_turn: false` when this turn's alive/ + * running lock was gone or reassigned since the last beat. `startAliveWatchdog`'s + * `onInterrupted` callback is how `server.ts` wires that into `controller.abort()` — these + * tests pin the watchdog's half of that contract via fetch interception. + */ +import { describe, it, beforeEach, afterEach, vi } from "vitest"; +import assert from "node:assert/strict"; + +const fetchCalls: Array<{ url: string; body: unknown }> = []; +let nextIsCurrentTurn: boolean | undefined = true; + +vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => { + const body = init?.body ? JSON.parse(init.body as string) : undefined; + fetchCalls.push({ url, body }); + const payload: Record = { ok: true }; + if (nextIsCurrentTurn !== undefined) { + payload.is_current_turn = nextIsCurrentTurn; + } + return new Response(JSON.stringify(payload), { status: 200 }); +}); + +const { startAliveWatchdog } = await import("../../src/sessions/alive.ts"); + +function flushMicrotasks(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +beforeEach(() => { + fetchCalls.length = 0; + nextIsCurrentTurn = true; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("startAliveWatchdog onInterrupted", () => { + it("does not fire onInterrupted while is_current_turn stays true", async () => { + const onInterrupted = vi.fn(); + const watchdog = await startAliveWatchdog( + "sess-ok", + "turn-ok", + "proj-1", + onInterrupted, + ); + await flushMicrotasks(); + + assert.equal(onInterrupted.mock.calls.length, 0); + await watchdog.release(); + }); + + it("fires onInterrupted when a beat reports is_current_turn: false", async () => { + const onInterrupted = vi.fn(); + const watchdog = await startAliveWatchdog( + "sess-cancelled", + "turn-cancelled", + "proj-1", + onInterrupted, + ); + await flushMicrotasks(); + assert.equal(onInterrupted.mock.calls.length, 0, "not interrupted yet"); + + // A cancel/steer/kill landed: the next beat reports the lock was taken. + nextIsCurrentTurn = false; + // Directly drive another beat by releasing and re-starting is unnecessary — the interval + // path is exercised in the interval test below; here we simulate the first beat itself + // being interrupted (e.g. the lock was already gone before the watchdog's first heartbeat + // observed it — a steer that raced session start). + const watchdog2 = await startAliveWatchdog( + "sess-cancelled-2", + "turn-cancelled-2", + "proj-1", + onInterrupted, + ); + await flushMicrotasks(); + + assert.equal(onInterrupted.mock.calls.length, 1); + + await watchdog.release(); + await watchdog2.release(); + }); + + it("fires onInterrupted at most once even if later beats keep reporting interrupted", async () => { + nextIsCurrentTurn = false; + const onInterrupted = vi.fn(); + const watchdog = await startAliveWatchdog( + "sess-repeat", + "turn-repeat", + "proj-1", + onInterrupted, + ); + await flushMicrotasks(); + assert.equal(onInterrupted.mock.calls.length, 1); + + // A second beat still reporting interrupted (e.g. release()'s final heartbeat) must not + // fire the callback again — the caller's controller.abort() is idempotent but the + // callback itself should still only ever fire once per turn. + await watchdog.release(); + assert.equal(onInterrupted.mock.calls.length, 1); + }); + + it("treats a network/HTTP failure as NOT interrupted (fail-open)", async () => { + vi.stubGlobal("fetch", async () => { + throw new Error("network down"); + }); + const onInterrupted = vi.fn(); + const watchdog = await startAliveWatchdog( + "sess-neterr", + "turn-neterr", + "proj-1", + onInterrupted, + ); + await flushMicrotasks(); + + assert.equal(onInterrupted.mock.calls.length, 0); + await assert.doesNotReject(() => watchdog.release()); + }); +}); diff --git a/services/runner/tests/unit/session-alive.test.ts b/services/runner/tests/unit/session-alive.test.ts index 168e7aecfd..1cff659c27 100644 --- a/services/runner/tests/unit/session-alive.test.ts +++ b/services/runner/tests/unit/session-alive.test.ts @@ -3,20 +3,28 @@ * * Verifies observable behaviors via fetch interception: * - the watchdog fires an immediate heartbeat on start + * - the FIRST heartbeat's response `stream.id` is captured and exposed via `streamId()` * - release() calls the heartbeat endpoint with is_running=false and status=ended - * - heartbeat failures are swallowed (no throw) + * - heartbeat failures are swallowed (no throw), and `streamId()` stays undefined */ import { describe, it, beforeEach, afterEach, vi } from "vitest"; import assert from "node:assert/strict"; const fetchCalls: Array<{ url: string; body: unknown }> = []; let fetchShouldFail = false; +let streamIdToReturn: string | undefined = "stream-default"; vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => { const body = init?.body ? JSON.parse(init.body as string) : undefined; fetchCalls.push({ url, body }); if (fetchShouldFail) return new Response("", { status: 500 }); - return new Response(JSON.stringify({ ok: true }), { status: 200 }); + return new Response( + JSON.stringify({ + ok: true, + stream: streamIdToReturn ? { id: streamIdToReturn } : undefined, + }), + { status: 200 }, + ); }); const { startAliveWatchdog } = await import("../../src/sessions/alive.ts"); @@ -29,6 +37,7 @@ function flushMicrotasks(): Promise { beforeEach(() => { fetchCalls.length = 0; fetchShouldFail = false; + streamIdToReturn = "stream-default"; }); afterEach(() => { @@ -37,8 +46,7 @@ afterEach(() => { describe("startAliveWatchdog", () => { it("sends an immediate heartbeat on start carrying turn_id and is_running=true", async () => { - const watchdog = startAliveWatchdog("sess-1", "turn-abc", "proj-1"); - await flushMicrotasks(); + const watchdog = await startAliveWatchdog("sess-1", "turn-abc", "proj-1"); const hb = fetchCalls.find((c) => c.url.includes("heartbeat")); assert.ok(hb, "expected a heartbeat call"); @@ -53,11 +61,35 @@ describe("startAliveWatchdog", () => { await watchdog.release(); }); + it("captures stream_id from the first heartbeat's response — no extra round-trip", async () => { + streamIdToReturn = "stream-xyz"; + const watchdog = await startAliveWatchdog("sess-1", "turn-abc", "proj-1"); + + assert.equal(watchdog.streamId(), "stream-xyz"); + // Exactly one heartbeat call was needed to obtain it. + const heartbeats = fetchCalls.filter((c) => c.url.includes("heartbeat")); + assert.equal(heartbeats.length, 1); + + await watchdog.release(); + }); + + it("streamId() is undefined when the heartbeat response carries no stream", async () => { + streamIdToReturn = undefined; + const watchdog = await startAliveWatchdog("sess-1", "turn-abc", "proj-1"); + assert.equal(watchdog.streamId(), undefined); + await watchdog.release(); + }); + + it("streamId() is undefined when the first heartbeat fails", async () => { + fetchShouldFail = true; + const watchdog = await startAliveWatchdog("sess-1", "turn-abc", "proj-1"); + assert.equal(watchdog.streamId(), undefined); + await watchdog.release(); + }); + it("uses a stable replica_id across turns (distinct from turn_id)", async () => { - const w1 = startAliveWatchdog("sess-a", "turn-1", "proj-1"); - await flushMicrotasks(); - const w2 = startAliveWatchdog("sess-b", "turn-2", "proj-1"); - await flushMicrotasks(); + const w1 = await startAliveWatchdog("sess-a", "turn-1", "proj-1"); + const w2 = await startAliveWatchdog("sess-b", "turn-2", "proj-1"); const replicas = fetchCalls .filter((c) => c.url.includes("heartbeat")) @@ -71,8 +103,7 @@ describe("startAliveWatchdog", () => { }); it("release() sends a final heartbeat with is_running=false (status derived server-side)", async () => { - const watchdog = startAliveWatchdog("sess-2", "turn-xyz", "proj-2"); - await flushMicrotasks(); + const watchdog = await startAliveWatchdog("sess-2", "turn-xyz", "proj-2"); fetchCalls.length = 0; // reset after the start heartbeat await watchdog.release(); @@ -82,15 +113,34 @@ describe("startAliveWatchdog", () => { const body = final.body as Record; assert.equal(body["is_running"], false); assert.equal(body["turn_id"], "turn-xyz"); - assert.ok(!("status" in body), "status was dropped from the heartbeat contract"); + assert.ok( + !("status" in body), + "status was dropped from the heartbeat contract", + ); }); it("swallows heartbeat failures — never throws", async () => { fetchShouldFail = true; - const watchdog = startAliveWatchdog("sess-3", "run-fail", "proj-3"); - await flushMicrotasks(); + const watchdog = await startAliveWatchdog("sess-3", "run-fail", "proj-3"); // release() should not throw even if fetch fails. await assert.doesNotReject(() => watchdog.release()); }); + + it("a later heartbeat updates streamId() when it returns a fresh id", async () => { + vi.useFakeTimers(); + try { + streamIdToReturn = "stream-first"; + const watchdog = await startAliveWatchdog("sess-1", "turn-abc", "proj-1"); + assert.equal(watchdog.streamId(), "stream-first"); + + streamIdToReturn = "stream-second"; + await vi.advanceTimersByTimeAsync(30_000); + assert.equal(watchdog.streamId(), "stream-second"); + + await watchdog.release(); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/services/runner/tests/unit/session-continuity-durable.test.ts b/services/runner/tests/unit/session-continuity-durable.test.ts index db85f41c9f..b70573b8f2 100644 --- a/services/runner/tests/unit/session-continuity-durable.test.ts +++ b/services/runner/tests/unit/session-continuity-durable.test.ts @@ -1,9 +1,9 @@ /** - * Unit tests for the durable mirror (`engines/sandbox_agent/session-continuity-durable.ts`). + * Unit tests for the durable turn log (`engines/sandbox_agent/session-continuity-durable.ts`). * * Exercised through injected fetch/deps -- no real HTTP. Covers: hydrate seeds an empty store - * from the durable row and never clobbers a live in-process record; both hydrate and sync are - * best-effort (a 503/throw never propagates out of a run). + * from the latest turn and never clobbers a live in-process record; append is a plain POST (no + * GET, no read-modify-write); both are best-effort (a 503/throw never propagates out of a run). * * Run: pnpm exec vitest run tests/unit/session-continuity-durable.test.ts */ @@ -11,8 +11,10 @@ import { describe, it } from "vitest"; import assert from "node:assert/strict"; import { + appendSessionTurn, + completeSessionTurn, + fetchLatestSessionTurn, hydrateHarnessSessionFromDurable, - syncHarnessSessionDurable, } from "../../src/engines/sandbox_agent/session-continuity-durable.ts"; import { SessionContinuityStore, @@ -22,28 +24,117 @@ import { const SILENT = () => {}; function okResponse(body: unknown): Response { - return { ok: true, status: 200, json: async () => body } as unknown as Response; + return { + ok: true, + status: 200, + json: async () => body, + } as unknown as Response; } function errResponse(status: number): Response { return { ok: false, status, json: async () => ({}) } as unknown as Response; } +describe("fetchLatestSessionTurn", () => { + it("POSTs a query scoped to session (+harness when given), windowed to the latest one", async () => { + let body: Record | undefined; + let url: string | undefined; + await fetchLatestSessionTurn("sess-1", "claude", { + apiBase: "http://api:8000", + authorization: "ApiKey abc", + fetchImpl: (async (u: string, init?: RequestInit) => { + url = u; + body = JSON.parse(init!.body as string); + return okResponse({ count: 0, turns: [] }); + }) as unknown as typeof fetch, + log: SILENT, + }); + assert.equal(url, "http://api:8000/sessions/turns/query"); + assert.deepEqual(body, { + query: { session_id: "sess-1", harness_kind: "claude" }, + windowing: { limit: 1, order: "descending" }, + }); + }); + + it("omits harness from the query when not given", async () => { + let body: Record | undefined; + await fetchLatestSessionTurn("sess-1", undefined, { + apiBase: "http://api:8000", + authorization: "ApiKey abc", + fetchImpl: (async (_u: string, init?: RequestInit) => { + body = JSON.parse(init!.body as string); + return okResponse({ turns: [] }); + }) as unknown as typeof fetch, + log: SILENT, + }); + assert.deepEqual(body!["query"], { session_id: "sess-1" }); + }); + + it("returns the first (latest) turn from the response", async () => { + const turn = await fetchLatestSessionTurn("sess-1", "claude", { + apiBase: "http://api:8000", + authorization: "ApiKey abc", + fetchImpl: (async () => + okResponse({ + count: 1, + turns: [ + { + harness_kind: "claude", + agent_session_id: "agent-1", + sandbox_id: "sbx-1", + turn_index: 3, + }, + ], + })) as unknown as typeof fetch, + log: SILENT, + }); + assert.deepEqual(turn, { + harness_kind: "claude", + agent_session_id: "agent-1", + sandbox_id: "sbx-1", + turn_index: 3, + }); + }); + + it("returns undefined on a non-2xx response, no throw", async () => { + const turn = await fetchLatestSessionTurn("sess-1", "claude", { + apiBase: "http://api:8000", + authorization: "ApiKey abc", + fetchImpl: (async () => errResponse(503)) as unknown as typeof fetch, + log: SILENT, + }); + assert.equal(turn, undefined); + }); + + it("returns undefined when fetch throws, no throw out", async () => { + const turn = await fetchLatestSessionTurn("sess-1", "claude", { + apiBase: "http://api:8000", + authorization: "ApiKey abc", + fetchImpl: (async () => { + throw new Error("ECONNREFUSED"); + }) as unknown as typeof fetch, + log: SILENT, + }); + assert.equal(turn, undefined); + }); +}); + describe("hydrateHarnessSessionFromDurable", () => { - it("seeds an empty store from the durable row", async () => { + it("seeds an empty store from the latest turn for this harness", async () => { const store = new SessionContinuityStore(); await hydrateHarnessSessionFromDurable("sess-1", "claude", store, { apiBase: "http://api:8000", authorization: "ApiKey abc", fetchImpl: (async () => okResponse({ - session_state: { - data: { - harness_sessions: { - claude: { agent_session_id: "agent-restored", turn_index: 2 }, - }, + turns: [ + { + harness_kind: "claude", + agent_session_id: "agent-restored", + turn_index: 2, + end_time: "2026-07-21T10:00:00.000Z", }, - }, + ], })) as unknown as typeof fetch, log: SILENT, }); @@ -54,6 +145,29 @@ describe("hydrateHarnessSessionFromDurable", () => { assert.equal(store.latestTurn("sess-1"), 2); }); + it("does not use a start-only row for native continuity", async () => { + const store = new SessionContinuityStore(); + await hydrateHarnessSessionFromDurable("sess-1", "claude", store, { + apiBase: "http://api:8000", + authorization: "ApiKey abc", + fetchImpl: (async () => + okResponse({ + turns: [ + { + harness_kind: "claude", + agent_session_id: "agent-partial", + turn_index: 3, + }, + ], + })) as unknown as typeof fetch, + log: SILENT, + }); + + assert.equal(store.get("sess-1", "claude"), undefined); + assert.equal(store.latestTurn("sess-1"), 3); + assert.equal(isHarnessLoadEligible("sess-1", "claude", store), false); + }); + it("never clobbers a live in-process record with a stale durable read", async () => { const store = new SessionContinuityStore(); store.record("sess-1", "claude", "agent-live", 5); @@ -62,13 +176,14 @@ describe("hydrateHarnessSessionFromDurable", () => { authorization: "ApiKey abc", fetchImpl: (async () => okResponse({ - session_state: { - data: { - harness_sessions: { - claude: { agent_session_id: "agent-OLD", turn_index: 0 }, - }, + turns: [ + { + harness_kind: "claude", + agent_session_id: "agent-OLD", + turn_index: 0, + end_time: "2026-07-21T10:00:00.000Z", }, - }, + ], })) as unknown as typeof fetch, log: SILENT, }); @@ -108,24 +223,13 @@ describe("hydrateHarnessSessionFromDurable", () => { assert.equal(store.get("sess-1", "claude"), undefined); }); - it("does nothing when the row has no record for this harness", async () => { + it("does nothing when there are no turns yet", async () => { const store = new SessionContinuityStore(); await hydrateHarnessSessionFromDurable("sess-1", "claude", store, { apiBase: "http://api:8000", authorization: "ApiKey abc", fetchImpl: (async () => - okResponse({ session_state: { data: { harness_sessions: {} } } })) as unknown as typeof fetch, - log: SILENT, - }); - assert.equal(store.get("sess-1", "claude"), undefined); - }); - - it("does nothing when session_state is null (no row yet)", async () => { - const store = new SessionContinuityStore(); - await hydrateHarnessSessionFromDurable("sess-1", "claude", store, { - apiBase: "http://api:8000", - authorization: "ApiKey abc", - fetchImpl: (async () => okResponse({ session_state: null })) as unknown as typeof fetch, + okResponse({ turns: [] })) as unknown as typeof fetch, log: SILENT, }); assert.equal(store.get("sess-1", "claude"), undefined); @@ -133,28 +237,49 @@ describe("hydrateHarnessSessionFromDurable", () => { it("restores the cross-harness latest_turn_index, keeping a stale harness INELIGIBLE after restart", async () => { // Durable state: claude ran turns 0-1, pi ran turn 2 (the latest). A fresh runner (empty - // store) returns to claude. Without restoring latest_turn_index, hydrate would set latest=1 - // (claude's own turn) and wrongly report claude eligible — the double-switch bug on restart. + // store) returns to claude. Without restoring the cross-harness counter, hydrate would set + // latest=1 (claude's own turn) and wrongly report claude eligible — the double-switch bug. const store = new SessionContinuityStore(); - const durableRow = { - session_state: { - data: { - latest_turn_index: 2, - harness_sessions: { - claude: { agent_session_id: "agent-claude", turn_index: 1 }, - pi: { agent_session_id: "agent-pi", turn_index: 2 }, - }, - }, - }, - }; - const fetchRow = (async () => okResponse(durableRow)) as unknown as typeof fetch; + const fetchImpl = (async (url: string, init?: RequestInit) => { + const body = JSON.parse(init!.body as string) as { + query: { harness_kind?: string }; + }; + if (body.query.harness_kind === "claude") { + return okResponse({ + turns: [ + { + harness_kind: "claude", + agent_session_id: "agent-claude", + turn_index: 1, + end_time: "2026-07-21T10:00:00.000Z", + }, + ], + }); + } + if (body.query.harness_kind === "pi") { + return okResponse({ + turns: [ + { harness_kind: "pi", agent_session_id: "agent-pi", turn_index: 2, end_time: "2026-07-21T10:00:00.000Z" }, + ], + }); + } + // overall latest (no harness filter): pi's turn 2 wins. + return okResponse({ + turns: [{ harness_kind: "pi", agent_session_id: "agent-pi", turn_index: 2, end_time: "2026-07-21T10:00:00.000Z" }], + }); + }) as unknown as typeof fetch; + await hydrateHarnessSessionFromDurable("sess-1", "claude", store, { apiBase: "http://api:8000", authorization: "ApiKey abc", - fetchImpl: fetchRow, + fetchImpl, log: SILENT, }); - assert.equal(store.latestTurn("sess-1"), 2, "latest must reflect pi's turn 2, not claude's 1"); + assert.equal( + store.latestTurn("sess-1"), + 2, + "latest must reflect pi's turn 2, not claude's 1", + ); assert.equal( isHarnessLoadEligible("sess-1", "claude", store), false, @@ -164,7 +289,7 @@ describe("hydrateHarnessSessionFromDurable", () => { await hydrateHarnessSessionFromDurable("sess-1", "pi", store, { apiBase: "http://api:8000", authorization: "ApiKey abc", - fetchImpl: fetchRow, + fetchImpl, log: SILENT, }); assert.equal(isHarnessLoadEligible("sess-1", "pi", store), true); @@ -173,158 +298,299 @@ describe("hydrateHarnessSessionFromDurable", () => { it("restores latest_turn_index even for a harness with no record of its own", async () => { const store = new SessionContinuityStore(); await hydrateHarnessSessionFromDurable("sess-1", "codex", store, { - apiBase: "http://api:8000", - authorization: "ApiKey abc", - fetchImpl: (async () => - okResponse({ - session_state: { - data: { - latest_turn_index: 4, - harness_sessions: { - claude: { agent_session_id: "agent-claude", turn_index: 4 }, - }, - }, - }, - })) as unknown as typeof fetch, - log: SILENT, - }); - assert.equal(store.get("sess-1", "codex"), undefined, "codex has no record of its own"); - assert.equal(store.latestTurn("sess-1"), 4, "but the conversation counter is still restored"); - }); -}); - -describe("syncHarnessSessionDurable", () => { - it("PUTs a read-modify-write merge that preserves other harnesses' entries", async () => { - const calls: Array<{ method: string; body?: unknown }> = []; - await syncHarnessSessionDurable("sess-1", "claude", "agent-new", 3, { - apiBase: "http://api:8000", - authorization: "ApiKey abc", - fetchImpl: (async (url: string, init?: RequestInit) => { - const method = init?.method ?? "GET"; - if (method === "GET") { - calls.push({ method }); - return okResponse({ - session_state: { - data: { - harness_sessions: { - pi: { agent_session_id: "agent-pi", turn_index: 1 }, - }, - }, - }, - }); - } - calls.push({ method, body: JSON.parse(init!.body as string) }); - return okResponse({}); - }) as unknown as typeof fetch, - log: SILENT, - }); - - const put = calls.find((c) => c.method === "PUT")!; - const data = (put.body as Record)["data"] as Record; - assert.equal(data["latest_agent_session_id"], "agent-new"); - assert.equal(data["latest_turn_index"], 3); - const harnessSessions = data["harness_sessions"] as Record; - assert.deepEqual(harnessSessions["claude"], { - agent_session_id: "agent-new", - turn_index: 3, - }); - assert.deepEqual(harnessSessions["pi"], { - agent_session_id: "agent-pi", - turn_index: 1, - }); - }); - - it("never regresses latest_turn_index below the durable row's existing value", async () => { - // This harness completes turn 2, but the durable row already recorded latest_turn_index=5 - // (another harness ran a later turn). The PUT must keep 5, not overwrite it down to 2. - let putBody: Record | undefined; - await syncHarnessSessionDurable("sess-1", "claude", "agent-new", 2, { apiBase: "http://api:8000", authorization: "ApiKey abc", fetchImpl: (async (_url: string, init?: RequestInit) => { - if ((init?.method ?? "GET") === "GET") { - return okResponse({ - session_state: { - data: { - latest_turn_index: 5, - harness_sessions: { - pi: { agent_session_id: "agent-pi", turn_index: 5 }, - }, - }, - }, - }); + const body = JSON.parse(init!.body as string) as { + query: { harness_kind?: string }; + }; + if (body.query.harness_kind === "codex") { + return okResponse({ turns: [] }); } - putBody = JSON.parse(init!.body as string); - return okResponse({}); + return okResponse({ + turns: [ + { + harness_kind: "claude", + agent_session_id: "agent-claude", + turn_index: 4, + end_time: "2026-07-21T10:00:00.000Z", + }, + ], + }); }) as unknown as typeof fetch, log: SILENT, }); - const putData = putBody!["data"] as Record; - assert.equal(putData["latest_turn_index"], 5, "must not regress the counter below 5"); assert.equal( - (putData["harness_sessions"] as Record)["claude"] !== undefined, - true, - "claude's own turn-2 record is still written", + store.get("sess-1", "codex"), + undefined, + "codex has no record of its own", + ); + assert.equal( + store.latestTurn("sess-1"), + 4, + "but the conversation counter is still restored", ); }); +}); - it("advances latest_turn_index when this turn is the highest", async () => { - let putBody: Record | undefined; - await syncHarnessSessionDurable("sess-1", "claude", "agent-new", 6, { - apiBase: "http://api:8000", - authorization: "ApiKey abc", - fetchImpl: (async (_url: string, init?: RequestInit) => { - if ((init?.method ?? "GET") === "GET") { - return okResponse({ - session_state: { data: { latest_turn_index: 5, harness_sessions: {} } }, +describe("appendSessionTurn", () => { + it("POSTs a plain create — no GET, one call only", async () => { + const calls: Array<{ method: string; url: string; body?: unknown }> = []; + await appendSessionTurn( + "sess-1", + "claude", + 3, + { + streamId: "stream-1", + turnId: "turn-execution-3", + agentSessionId: "agent-new", + }, + { + apiBase: "http://api:8000", + authorization: "ApiKey abc", + fetchImpl: (async (url: string, init?: RequestInit) => { + calls.push({ + method: init?.method ?? "GET", + url, + body: init?.body ? JSON.parse(init.body as string) : undefined, }); - } - putBody = JSON.parse(init!.body as string); - return okResponse({}); - }) as unknown as typeof fetch, - log: SILENT, + return okResponse({ turn: {} }); + }) as unknown as typeof fetch, + log: SILENT, + }, + ); + assert.equal( + calls.length, + 1, + "append must be a single POST, no read-modify-write", + ); + assert.equal(calls[0].method, "POST"); + assert.equal(calls[0].url, "http://api:8000/sessions/turns/"); + assert.deepEqual(calls[0].body, { + session_id: "sess-1", + stream_id: "stream-1", + turn_id: "turn-execution-3", + turn_index: 3, + harness_kind: "claude", + agent_session_id: "agent-new", }); - assert.equal((putBody!["data"] as Record)["latest_turn_index"], 6); }); - it("never throws when the GET fails (falls back to an empty merge base)", async () => { - await assert.doesNotReject(() => - syncHarnessSessionDurable("sess-1", "claude", "agent-new", 0, { + it("carries sandbox, references, trace ids, and start_time when given", async () => { + let body: Record | undefined; + await appendSessionTurn( + "sess-1", + "claude", + 1, + { + streamId: "stream-1", + agentSessionId: "agent-1", + sandboxId: "sbx-1", + references: [{ id: "wf-1" }, { id: "rev-1", version: "v1" }], + traceId: "trace-abc", + spanId: "a1b2c3d4e5f6a7b8", + startTime: "2026-07-21T10:00:00.000Z", + }, + { apiBase: "http://api:8000", authorization: "ApiKey abc", fetchImpl: (async (_url: string, init?: RequestInit) => { - if ((init?.method ?? "GET") === "GET") return errResponse(503); - return okResponse({}); + body = JSON.parse(init!.body as string); + return okResponse({ turn: {} }); }) as unknown as typeof fetch, log: SILENT, - }), + }, ); + assert.equal(body!["sandbox_id"], "sbx-1"); + assert.deepEqual(body!["references"], [ + { id: "wf-1" }, + { id: "rev-1", version: "v1" }, + ]); + assert.equal(body!["trace_id"], "trace-abc"); + assert.equal(body!["span_id"], "a1b2c3d4e5f6a7b8"); + assert.equal(body!["start_time"], "2026-07-21T10:00:00.000Z"); }); - it("never throws when the whole call chain throws (network error)", async () => { + it("treats a resume execution's duplicate-start 409 as benign", async () => { + const logs: string[] = []; await assert.doesNotReject(() => - syncHarnessSessionDurable("sess-1", "claude", "agent-new", 0, { - apiBase: "http://api:8000", - authorization: "ApiKey abc", - fetchImpl: (async () => { - throw new Error("ECONNREFUSED"); - }) as unknown as typeof fetch, - log: SILENT, - }), + appendSessionTurn( + "sess-1", + "claude", + 0, + { streamId: "stream-1" }, + { + apiBase: "http://api:8000", + authorization: "ApiKey abc", + fetchImpl: (async () => errResponse(409)) as unknown as typeof fetch, + log: (message) => logs.push(message), + }, + ), ); + assert.deepEqual(logs, []); }); - it("never throws when the PUT itself returns a non-2xx (503)", async () => { + it("never throws when the POST fails (503)", async () => { await assert.doesNotReject(() => - syncHarnessSessionDurable("sess-1", "claude", "agent-new", 0, { - apiBase: "http://api:8000", - authorization: "ApiKey abc", - fetchImpl: (async (_url: string, init?: RequestInit) => { - if ((init?.method ?? "GET") === "GET") return errResponse(404); - return errResponse(503); - }) as unknown as typeof fetch, - log: SILENT, - }), + appendSessionTurn( + "sess-1", + "claude", + 0, + { streamId: "stream-1" }, + { + apiBase: "http://api:8000", + authorization: "ApiKey abc", + fetchImpl: (async () => errResponse(503)) as unknown as typeof fetch, + log: SILENT, + }, + ), + ); + }); + + it("never throws when fetch itself throws (network error)", async () => { + await assert.doesNotReject(() => + appendSessionTurn( + "sess-1", + "claude", + 0, + { streamId: "stream-1" }, + { + apiBase: "http://api:8000", + authorization: "ApiKey abc", + fetchImpl: (async () => { + throw new Error("ECONNREFUSED"); + }) as unknown as typeof fetch, + log: SILENT, + }, + ), + ); + }); +}); + +describe("completeSessionTurn", () => { + it("a paused-then-completed turn keeps one row and fills its completion fields", async () => { + const rows = new Map>(); + const calls: Array<{ url: string; body: Record }> = []; + const deps = { + apiBase: "http://api:8000", + authorization: "ApiKey abc", + fetchImpl: (async (url: string, init?: RequestInit) => { + const body = JSON.parse(init!.body as string) as Record; + calls.push({ url, body }); + const key = `${body["session_id"]}:${body["turn_index"]}`; + if (url.endsWith("/sessions/turns/")) { + if (rows.has(key)) return errResponse(409); + rows.set(key, { ...body }); + return okResponse({ turn: rows.get(key) }); + } + + const row = rows.get(key); + if (!row) return errResponse(404); + if (!row["end_time"]) { + row["end_time"] = body["end_time"]; + if (body["agent_session_id"]) { + row["agent_session_id"] = body["agent_session_id"]; + } + } + return okResponse({ turn: row }); + }) as unknown as typeof fetch, + log: SILENT, + }; + + await appendSessionTurn( + "sess-1", + "claude", + 0, + { + streamId: "stream-1", + turnId: "turn-execution-start", + traceId: "trace-1", + spanId: "a1b2c3d4e5f6a7b8", + startTime: "2026-07-21T10:00:00.000Z", + }, + deps, + ); + await appendSessionTurn( + "sess-1", + "claude", + 0, + { + streamId: "stream-1", + turnId: "turn-execution-resume", + agentSessionId: "agent-1", + startTime: "2026-07-21T10:01:00.000Z", + }, + deps, + ); + await completeSessionTurn( + "sess-1", + 0, + { + agentSessionId: "agent-1", + endTime: "2026-07-21T10:02:00.000Z", + }, + deps, + ); + + assert.equal(rows.size, 1); + assert.deepEqual(rows.get("sess-1:0"), { + session_id: "sess-1", + stream_id: "stream-1", + turn_id: "turn-execution-start", + turn_index: 0, + harness_kind: "claude", + trace_id: "trace-1", + span_id: "a1b2c3d4e5f6a7b8", + start_time: "2026-07-21T10:00:00.000Z", + agent_session_id: "agent-1", + end_time: "2026-07-21T10:02:00.000Z", + }); + assert.equal(calls[2].url, "http://api:8000/sessions/turns/complete"); + assert.deepEqual(calls[2].body, { + session_id: "sess-1", + turn_index: 0, + agent_session_id: "agent-1", + end_time: "2026-07-21T10:02:00.000Z", + }); + }); +}); + +describe("two turns append cleanly (no RMW race)", () => { + it("appending turn N+1 does not read or depend on turn N's row", async () => { + // The defining behavioral change: unlike the old GET-then-PUT, two sequential appends never + // issue a GET, so there is nothing for a concurrent writer to race. + const posts: Array> = []; + const deps = { + apiBase: "http://api:8000", + authorization: "ApiKey abc", + fetchImpl: (async (_url: string, init?: RequestInit) => { + assert.equal( + init?.method, + "POST", + "every call must be a POST, never a GET", + ); + posts.push(JSON.parse(init!.body as string)); + return okResponse({ turn: {} }); + }) as unknown as typeof fetch, + log: SILENT, + }; + await appendSessionTurn( + "sess-1", + "claude", + 0, + { streamId: "stream-1", agentSessionId: "agent-a" }, + deps, + ); + await appendSessionTurn( + "sess-1", + "claude", + 1, + { streamId: "stream-1", agentSessionId: "agent-b" }, + deps, ); + assert.equal(posts.length, 2); + assert.equal(posts[0]["turn_index"], 0); + assert.equal(posts[1]["turn_index"], 1); + assert.equal(posts[1]["agent_session_id"], "agent-b"); }); }); diff --git a/services/runner/tests/unit/session-interactions.test.ts b/services/runner/tests/unit/session-interactions.test.ts index 584aca2358..546bb0641c 100644 --- a/services/runner/tests/unit/session-interactions.test.ts +++ b/services/runner/tests/unit/session-interactions.test.ts @@ -20,7 +20,8 @@ vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => { return new Response(JSON.stringify({ ok: true }), { status: 200 }); }); -const { createInteraction } = await import("../../src/sessions/interactions.ts"); +const { createInteraction, resolveInteraction } = + await import("../../src/sessions/interactions.ts"); beforeEach(() => { postedBodies.length = 0; @@ -72,3 +73,34 @@ describe("createInteraction", () => { ); }); }); + +describe("resolveInteraction", () => { + it("POSTs the approval resolution with full-word verdict fields", async () => { + await resolveInteraction( + "sess-5", + "tok-resolution", + () => "Secret t", + { verdict: "approved", tool_call_id: "tool-5" }, + ); + + assert.equal(postedBodies.length, 1); + const { url, body } = postedBodies[0]; + assert.ok(url.endsWith("/sessions/interactions/transition")); + assert.deepEqual(body, { + session_id: "sess-5", + token: "tok-resolution", + status: "resolved", + resolution: { verdict: "approved", tool_call_id: "tool-5" }, + }); + }); + + it("omits resolution when the transition has no verdict", async () => { + await resolveInteraction("sess-6", "tok-client", () => "Secret t"); + + assert.deepEqual(postedBodies[0].body, { + session_id: "sess-6", + token: "tok-client", + status: "resolved", + }); + }); +}); diff --git a/services/runner/tests/unit/session-keepalive-approval.test.ts b/services/runner/tests/unit/session-keepalive-approval.test.ts index d0db503bbc..6035c7f232 100644 --- a/services/runner/tests/unit/session-keepalive-approval.test.ts +++ b/services/runner/tests/unit/session-keepalive-approval.test.ts @@ -20,6 +20,7 @@ import type { } from "../../src/protocol.ts"; import { runWithKeepalive, + staleInteractionExemptTokens, type KeepaliveContext, type KeepaliveEngine, } from "../../src/server.ts"; @@ -33,7 +34,12 @@ import { type SandboxAgentDeps, type SessionEnvironment, } from "../../src/engines/sandbox_agent.ts"; -import { TOOL_NOT_EXECUTED_PAUSED } from "../../src/tracing/otel.ts"; +import { + APPROVED_EXECUTION_RESULT_UNKNOWN, + createSandboxAgentOtel, + DEFERRED_NOT_EXECUTED_PREFIX, + TOOL_NOT_EXECUTED_PAUSED, +} from "../../src/tracing/otel.ts"; function flush(): Promise { return new Promise((resolve) => setImmediate(resolve)); @@ -50,13 +56,25 @@ const auth = { // ---------------------------------------------------------------------------- // interface TurnScript { - /** The turn pauses on a single parkable permission gate (records parkedApproval). */ + /** The turn pauses on one or more parkable permission gates (records parkedApprovals). */ approvalPause?: { permissionId: string; toolCallId: string; toolName?: string; - /** How many gates pended (>1 = multi-gate; still records the first). Default 1. */ + /** Additional parkable gates in the same turn (parallel gated tool calls). */ + extraGates?: Array<{ + permissionId: string; + toolCallId: string; + toolName?: string; + }>; + /** + * Override `approvalGateCount`. Defaults to the number of recorded gates. Set LARGER than the + * recorded gates to model a gate that lacked a resumable id (count > map size -> unresumable, + * no park). + */ gates?: number; + /** Also flag a non-parkable (client-tool) pause this turn, so the mixed set stays cold. */ + nonParkable?: boolean; /** The parked gate plane; default the Claude ACP gate. */ gateType?: ParkedApproval["gateType"]; }; @@ -75,8 +93,10 @@ interface DispatchFakeEnv { destroyed: number; turnsCleared: number; lastTurnToolCallIds: string[]; + parkedApprovals: Map; parkedApproval?: ParkedApproval; approvalGateCount: number; + nonParkablePauseCount: number; clearTurn: () => void; destroyImpl: () => Promise; destroy: () => Promise; @@ -112,8 +132,10 @@ function makeApprovalEngine( destroyed: 0, turnsCleared: 0, lastTurnToolCallIds: [], + parkedApprovals: new Map(), parkedApproval: undefined, approvalGateCount: 0, + nonParkablePauseCount: 0, clearTurn: () => { env.turnsCleared += 1; }, @@ -141,42 +163,73 @@ function makeApprovalEngine( ): Promise => { const idx = calls.turns.length; const script = scripts[idx] ?? {}; - // Mirror the real runTurn per-turn reset. + // Mirror the real runTurn per-turn reset and carried-gate reseed. + env.parkedApprovals = new Map(); env.parkedApproval = undefined; env.approvalGateCount = 0; + env.nonParkablePauseCount = 0; + for (const gate of opts?.resume?.carriedForward ?? []) { + env.parkedApprovals.set(gate.toolCallId, gate); + env.parkedApproval ??= gate; + } + env.approvalGateCount = env.parkedApprovals.size; env.lastTurnToolCallIds = script.toolCallIds ?? []; calls.turns.push({ env, opts, idx }); if (opts?.resume) { - calls.resumes.push({ - permissionId: opts.resume.permissionId, - reply: opts.resume.reply, - toolCallId: opts.resume.toolCallId, - }); + // The warm resume carries a LIST of decisions (one per parked gate). + for (const decision of opts.resume.decisions) { + calls.resumes.push({ + permissionId: decision.permissionId, + reply: decision.reply, + toolCallId: decision.toolCallId, + }); + } } if (script.hold) { await new Promise((resolve) => holds.set(idx, resolve)); } if (script.approvalPause) { - env.approvalGateCount = script.approvalPause.gates ?? 1; + const gateType = + script.approvalPause.gateType ?? "claude-acp-permission"; + const parkableGates = [ + { + permissionId: script.approvalPause.permissionId, + toolCallId: script.approvalPause.toolCallId, + toolName: script.approvalPause.toolName, + }, + ...(script.approvalPause.extraGates ?? []), + ]; + // approvalGateCount defaults to the recorded-gate count; a larger override models a gate + // that lacked a resumable id (count > map size -> the dispatch treats the set as unresumable). + env.approvalGateCount = script.approvalPause.gates ?? parkableGates.length; + env.nonParkablePauseCount = script.approvalPause.nonParkable ? 1 : 0; // The held original prompt: pending until the test settles it (mirrors the real Claude - // prompt that never resolves on an unanswered gate). Carries the same swallowing catch - // runTurn attaches, so a test-driven rejection is never unhandled. + // prompt that never resolves on an unanswered gate). One prompt per turn, shared by every + // parked gate. Carries the same swallowing catch runTurn attaches, so a test-driven + // rejection is never unhandled. const promptPromise = new Promise((resolve, reject) => { calls.promptControls.push({ resolve, reject }); }); promptPromise.catch(() => {}); - env.parkedApproval = { - gateType: script.approvalPause.gateType ?? "claude-acp-permission", - permissionId: script.approvalPause.permissionId, - toolCallId: script.approvalPause.toolCallId, - toolName: script.approvalPause.toolName, - args: {}, - interactionToken: script.approvalPause.toolCallId, - promptPromise, - }; + for (const gate of parkableGates) { + const record: ParkedApproval = { + gateType, + permissionId: gate.permissionId, + toolCallId: gate.toolCallId, + toolName: gate.toolName, + args: {}, + interactionToken: gate.toolCallId, + promptPromise, + }; + env.parkedApprovals.set(gate.toolCallId, record); + env.parkedApproval ??= record; + } return { ok: true, stopReason: "paused" }; } if (script.nonClaudePause) return { ok: true, stopReason: "paused" }; + if (opts?.resume?.carriedForward?.length) { + return { ok: true, stopReason: "paused" }; + } return script.result ?? { ok: true, output: "ok", stopReason: "complete" }; }; @@ -268,6 +321,49 @@ function approveResume( }; } +/** A resume carrying every parked call plus the answer envelopes supplied in this request. */ +function approveResumeMulti( + answers: Array<{ toolCallId: string; toolName?: string; approved: boolean }>, + parkedCalls: Array<{ toolCallId: string; toolName?: string }> = answers.map(({ toolCallId, toolName }) => ({ toolCallId, toolName })), + priorAnswers: Array<{ toolCallId: string; approved: boolean }> = [], +): AgentRunRequest { + return { + harness: "claude", + model: "m1", + sessionId: "s1", + ...auth, + messages: [ + { role: "user", content: "do X" }, + { + role: "assistant", + content: parkedCalls.map((gate) => ({ + type: "tool_call", + toolCallId: gate.toolCallId, + toolName: gate.toolName ?? "commit", + })), + }, + ...priorAnswers.map((answer) => ({ + role: "user" as const, + content: [ + { + type: "tool_result" as const, + toolCallId: answer.toolCallId, + output: { approved: answer.approved }, + }, + ], + })), + { + role: "user", + content: answers.map((answer) => ({ + type: "tool_result", + toolCallId: answer.toolCallId, + output: { approved: answer.approved }, + })), + }, + ], + }; +} + function captureStderr() { const lines: string[] = []; const orig = process.stderr.write.bind(process.stderr); @@ -416,11 +512,209 @@ describe("runWithKeepalive: approval park + resume", () => { await runWithKeepalive(pauseTurn(), undefined, undefined, ctx); await runWithKeepalive(approveResume(true), undefined, undefined, ctx); assert.ok(cap.lines.some((l) => l.includes("park-approval"))); - assert.ok(cap.lines.some((l) => l.includes("resume-approve"))); + assert.ok( + cap.lines.some((l) => l.includes("resume ") && l.includes("approve=1")), + ); } finally { cap.restore(); } }); + + it("parks a two-gate turn and answers BOTH gates live on one resume", async () => { + const { engine, calls } = makeApprovalEngine([ + { + approvalPause: { + permissionId: "perm-1", + toolCallId: "tc-1", + toolName: "read_a", + extraGates: [ + { permissionId: "perm-2", toolCallId: "tc-2", toolName: "read_b" }, + ], + }, + toolCallIds: ["tc-1", "tc-2"], + }, + ]); + const ctx = makeCtx(engine); + + const r1 = await runWithKeepalive(pauseTurn(), undefined, undefined, ctx); + assert.equal(r1.stopReason, "paused"); + assert.equal( + ctx.pool.get(POOL_KEY)!.state, + "awaiting_approval", + "a two-gate turn parks (no longer forced cold)", + ); + + const r2 = await runWithKeepalive( + approveResumeMulti([ + { toolCallId: "tc-1", toolName: "read_a", approved: true }, + { toolCallId: "tc-2", toolName: "read_b", approved: true }, + ]), + undefined, + undefined, + ctx, + ); + assert.equal(r2.ok, true); + assert.equal(calls.acquire, 1, "the resume did NOT re-acquire cold"); + assert.equal( + calls.resumes.length, + 2, + "respondPermission is called once per parked gate", + ); + assert.deepEqual( + calls.resumes.map((r) => `${r.toolCallId}:${r.reply}`).sort(), + ["tc-1:once", "tc-2:once"], + ); + }); + + it("resumes a two-gate turn with deny-one-approve-one, each gate answered on its own id", async () => { + const { engine, calls } = makeApprovalEngine([ + { + approvalPause: { + permissionId: "perm-1", + toolCallId: "tc-1", + toolName: "read_a", + extraGates: [ + { permissionId: "perm-2", toolCallId: "tc-2", toolName: "read_b" }, + ], + }, + toolCallIds: ["tc-1", "tc-2"], + }, + ]); + const ctx = makeCtx(engine); + await runWithKeepalive(pauseTurn(), undefined, undefined, ctx); + + const r2 = await runWithKeepalive( + approveResumeMulti([ + { toolCallId: "tc-1", toolName: "read_a", approved: false }, + { toolCallId: "tc-2", toolName: "read_b", approved: true }, + ]), + undefined, + undefined, + ctx, + ); + assert.equal(r2.ok, true); + assert.equal(calls.resumes.length, 2); + assert.deepEqual( + calls.resumes.map((r) => `${r.toolCallId}:${r.reply}`).sort(), + ["tc-1:reject", "tc-2:once"], + "the denied gate rejects and the approved gate runs, each by its own id", + ); + }); + + it("spares every parked token while a partial answer re-parks and then completes live", async () => { + const { engine, calls } = makeApprovalEngine([ + { + approvalPause: { + permissionId: "perm-1", + toolCallId: "tc-1", + toolName: "read_a", + extraGates: [ + { permissionId: "perm-2", toolCallId: "tc-2", toolName: "read_b" }, + ], + }, + toolCallIds: ["tc-1", "tc-2"], + }, + ]); + const ctx = makeCtx(engine); + const parkedCalls = [ + { toolCallId: "tc-1", toolName: "read_a" }, + { toolCallId: "tc-2", toolName: "read_b" }, + ]; + await runWithKeepalive(pauseTurn(), undefined, undefined, ctx); + + const partialRequest = approveResumeMulti( + [{ toolCallId: "tc-1", toolName: "read_a", approved: true }], + parkedCalls, + ); + assert.deepEqual( + staleInteractionExemptTokens( + partialRequest, + calls.acquiredEnvs[0].parkedApprovals, + ), + ["tc-1", "tc-2"], + "the carried gate must survive the turn-start stale sweep", + ); + + const r2 = await runWithKeepalive( + partialRequest, + undefined, + undefined, + ctx, + ); + assert.equal(r2.stopReason, "paused"); + assert.equal(calls.acquire, 1, "the partial answer stayed on the live environment"); + assert.deepEqual(calls.resumes, [ + { permissionId: "perm-1", reply: "once", toolCallId: "tc-1" }, + ]); + assert.equal(ctx.pool.get(POOL_KEY)?.state, "awaiting_approval"); + assert.deepEqual( + [...calls.acquiredEnvs[0].parkedApprovals.keys()], + ["tc-2"], + "the untouched gate re-parked with a fresh approval lease", + ); + assert.deepEqual( + calls.turns[1].opts.resume.carriedForward.map((gate: ParkedApproval) => + gate.permissionId, + ), + ["perm-2"], + ); + + const r3 = await runWithKeepalive( + approveResumeMulti( + [{ toolCallId: "tc-2", toolName: "read_b", approved: false }], + parkedCalls, + [{ toolCallId: "tc-1", approved: true }], + ), + undefined, + undefined, + ctx, + ); + assert.equal(r3.stopReason, "complete"); + assert.equal(calls.acquire, 1, "both partial requests matched the live history fingerprint"); + assert.deepEqual(calls.resumes, [ + { permissionId: "perm-1", reply: "once", toolCallId: "tc-1" }, + { permissionId: "perm-2", reply: "reject", toolCallId: "tc-2" }, + ]); + assert.equal(ctx.pool.get(POOL_KEY)?.state, "idle"); + }); + + it("evicts a two-gate park when the request answers zero gates", async () => { + const { engine, calls } = makeApprovalEngine([ + { + approvalPause: { + permissionId: "perm-1", + toolCallId: "tc-1", + extraGates: [{ permissionId: "perm-2", toolCallId: "tc-2" }], + }, + toolCallIds: ["tc-1", "tc-2"], + }, + ]); + const ctx = makeCtx(engine); + await runWithKeepalive(pauseTurn(), undefined, undefined, ctx); + + const zeroAnswerRequest = approveResumeMulti( + [], + [{ toolCallId: "tc-1" }, { toolCallId: "tc-2" }], + ); + assert.equal( + staleInteractionExemptTokens( + zeroAnswerRequest, + calls.acquiredEnvs[0].parkedApprovals, + ), + undefined, + "zero answers leave stale cancellation enabled", + ); + + await runWithKeepalive( + zeroAnswerRequest, + undefined, + undefined, + ctx, + ); + + assert.equal(calls.resumes.length, 0); + assert.equal(calls.acquire, 2, "zero answers kept the existing cold fallback"); + }); }); describe("runWithKeepalive: never-park gate types stay cold", () => { @@ -443,9 +737,11 @@ describe("runWithKeepalive: never-park gate types stay cold", () => { } }); - it("a multi-gate pause (parallel gates) never parks, tears down cold", async () => { + it("a turn with an unresumable gate (pending count exceeds recorded gates) stays cold", async () => { const cap = captureStderr(); try { + // Two gates pended but only one carried a resumable id (map size 1, count 2): the set is not + // fully resumable, so the whole turn falls to the cold path. const { engine, calls } = makeApprovalEngine([ { approvalPause: { @@ -463,10 +759,37 @@ describe("runWithKeepalive: never-park gate types stay cold", () => { assert.equal( calls.acquiredEnvs[0].destroyed, 1, - "the single-gate resume cannot answer >1 gate -> cold", + "a gate that cannot be resumed live -> cold", ); assert.equal(ctx.pool.size(), 0); - assert.ok(cap.lines.some((l) => l.includes("multi-gate-no-park"))); + assert.ok(cap.lines.some((l) => l.includes("unresumable-gate-no-park"))); + } finally { + cap.restore(); + } + }); + + it("a mixed set (an approval gate plus a client-tool pause) stays cold", async () => { + const cap = captureStderr(); + try { + // One parkable approval gate AND one non-parkable client-tool pause in the same turn: only + // the cold path can multiplex the mixed set, so the whole turn stays cold. + const { engine, calls } = makeApprovalEngine([ + { + approvalPause: { + permissionId: "perm-1", + toolCallId: "tc-gate", + toolName: "commit", + nonParkable: true, + }, + toolCallIds: ["tc-gate"], + }, + ]); + const ctx = makeCtx(engine); + const r = await runWithKeepalive(pauseTurn(), undefined, undefined, ctx); + assert.equal(r.stopReason, "paused"); + assert.equal(calls.acquiredEnvs[0].destroyed, 1, "mixed set -> cold"); + assert.equal(ctx.pool.size(), 0); + assert.ok(cap.lines.some((l) => l.includes("mixed-gate-no-park"))); } finally { cap.restore(); } @@ -873,9 +1196,24 @@ interface FakeRun { open: Set; /** What settleOpenToolCalls settled: the orphaned-sibling record (F-024). */ settled: Array<{ id: string; message: string }>; + denied: string[]; + sweeps: Array<{ + message: string; + isExcluded: (id: string) => boolean; + }>; +} + +interface PiBatchCall { + permissionId: string; + toolCallId: string; + toolName: string; + args: unknown; + output: string; } -function pausableHarness(opts: { clientTool?: boolean } = {}) { +function pausableHarness( + opts: { clientTool?: boolean; piBatching?: PiBatchCall[] } = {}, +) { const calls = { permissionReplies: [] as Array<{ id: string; reply: string }>, runs: [] as FakeRun[], @@ -890,6 +1228,49 @@ function pausableHarness(opts: { clientTool?: boolean } = {}) { onEvent: undefined as ((event: any) => void) | undefined, onPermissionRequest: undefined as ((req: any) => void) | undefined, }; + const answeredPiBatchPermissions = new Set(); + const emittedPiBatchPermissions = new Set(); + const emitPiBatchGate = (call: PiBatchCall): void => { + if (emittedPiBatchPermissions.has(call.permissionId)) return; + emittedPiBatchPermissions.add(call.permissionId); + captured.onEvent?.({ + payload: { + update: { + sessionUpdate: "tool_call", + toolCallId: call.toolCallId, + title: call.toolName, + rawInput: call.args, + }, + }, + }); + captured.onPermissionRequest?.({ + id: call.permissionId, + availableReplies: ["once", "reject"], + toolCall: { + toolCallId: call.toolCallId, + name: call.toolName, + rawInput: call.args, + }, + }); + }; + const emitPiBatchResults = (): void => { + for (const call of opts.piBatching ?? []) { + captured.onEvent?.({ + payload: { + update: { + sessionUpdate: "tool_call_update", + toolCallId: call.toolCallId, + status: "completed", + content: call.output, + }, + }, + }); + } + calls.resolvePrompt?.({ + stopReason: "complete", + usage: { inputTokens: 1, outputTokens: 1 }, + }); + }; const session = { id: "session-1", @@ -901,6 +1282,17 @@ function pausableHarness(opts: { clientTool?: boolean } = {}) { }, async respondPermission(id: string, reply: string) { calls.permissionReplies.push({ id, reply }); + const batch = opts.piBatching; + if (!batch || reply !== "once") return; + const index = batch.findIndex((call) => call.permissionId === id); + if (index < 0) return; + answeredPiBatchPermissions.add(id); + // Pi resolves every approval hook before executing any call in the parallel batch. + if (index + 1 < batch.length) { + queueMicrotask(() => emitPiBatchGate(batch[index + 1])); + } else if (answeredPiBatchPermissions.size === batch.length) { + queueMicrotask(emitPiBatchResults); + } }, prompt(_blocks: any) { calls.promptCount += 1; @@ -908,6 +1300,10 @@ function pausableHarness(opts: { clientTool?: boolean } = {}) { // it — modelling the ORIGINAL prompt continuing after the parked gate is answered. return new Promise((resolve) => { calls.resolvePrompt = resolve; + const firstPiBatchCall = opts.piBatching?.[0]; + if (firstPiBatchCall) { + queueMicrotask(() => emitPiBatchGate(firstPiBatchCall)); + } }); }, }; @@ -934,6 +1330,7 @@ function pausableHarness(opts: { clientTool?: boolean } = {}) { emitted: [], open: new Set(), settled: [], + sweeps: [], start() {}, // Track open tool calls the way the real otel run does, so settleOpenToolCalls is // meaningful: a tool_call opens an id, a completed/failed tool_call_update closes it. @@ -972,12 +1369,16 @@ function pausableHarness(opts: { clientTool?: boolean } = {}) { isExcluded: (id: string) => boolean, message: string, ) { + run.sweeps.push({ message, isExcluded }); for (const id of [...run.open]) { if (isExcluded(id)) continue; run.open.delete(id); run.settled.push({ id, message }); } }, + openToolCallIds() { + return [...run.open]; + }, denied: [] as string[], markToolCallDenied(id: string | undefined) { if (id) run.denied.push(id); @@ -1098,13 +1499,18 @@ describe("runTurn: real approval park + respondPermission resume", () => { const p2 = runTurn(env, approveResume(true), undefined, undefined, { approvalParkMode: true, resume: { - permissionId: "perm-1", - reply: "once", - toolCallId: "tc-gate", - toolName: "commit", - args: { message: "hi" }, - interactionToken: "tc-gate", - promptPromise: held, + decisions: [ + { + permissionId: "perm-1", + reply: "once", + toolCallId: "tc-gate", + toolName: "commit", + args: { message: "hi" }, + interactionToken: "tc-gate", + promptPromise: held, + }, + ], + carriedForward: [], }, }); await flush(); @@ -1164,49 +1570,1202 @@ describe("runTurn: real approval park + respondPermission resume", () => { "the post-resume tool_call_update streamed (not suppressed) into the new run", ); + assert.deepEqual( + run2.emitted.filter((event) => event.type === "interaction_response"), + [ + { + type: "interaction_response", + id: "tc-gate", + kind: "user_approval", + payload: { toolCallId: "tc-gate", approved: true }, + }, + ], + ); + await env.destroy(); }); - it("forwards a reject on the resume when the decision is deny", async () => { + it("creates and resolves a durable gate row without workflow context", async () => { + const posted: Array<{ url: string; body: Record }> = []; + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async (input, init) => { + posted.push({ + url: String(input), + body: JSON.parse(init?.body as string) as Record, + }); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }); + + try { + const { calls, deps, captured } = pausableHarness(); + deps.hydrateHarnessSessionFromDurable = async () => {}; + const noWorkflowRequest: AgentRunRequest = { + ...engineReq, + ...auth, + sessionId: "sess-no-workflow", + turnId: "turn-no-workflow-start", + }; + const acquired = await acquireEnvironment(noWorkflowRequest, deps); + assert.equal(acquired.ok, true); + if (!acquired.ok) return; + const env = acquired.env; + + const firstTurn = runTurn( + env, + noWorkflowRequest, + undefined, + undefined, + { approvalParkMode: true }, + ); + await flush(); + captured.onEvent!( + updateEvent({ + sessionUpdate: "tool_call", + toolCallId: "tc-no-workflow", + title: "commit", + rawInput: { message: "hi" }, + }), + ); + captured.onPermissionRequest!({ + id: "perm-no-workflow", + availableReplies: ["once", "reject"], + toolCall: { + toolCallId: "tc-no-workflow", + name: "commit", + rawInput: { message: "hi" }, + }, + }); + const paused = await firstTurn; + assert.equal(paused.stopReason, "paused"); + await flush(); + + const createCall = posted.find(({ url }) => + url.endsWith("/sessions/interactions/"), + ); + assert.ok(createCall); + assert.equal(createCall.body["session_id"], "sess-no-workflow"); + assert.equal(createCall.body["turn_id"], "turn-no-workflow-start"); + const createData = createCall.body["data"] as Record; + assert.equal("references" in createData, false); + + const parked = env.parkedApprovals.get("tc-no-workflow"); + assert.ok(parked); + assert.ok(parked.promptPromise); + env.clearTurn(); + const resumedTurn = runTurn( + env, + approveResume(true, { + sessionId: "sess-no-workflow", + turnId: "turn-no-workflow-resume", + }), + undefined, + undefined, + { + approvalParkMode: true, + resume: { + decisions: [ + { + permissionId: parked.permissionId, + reply: "once", + toolCallId: parked.toolCallId, + toolName: parked.toolName, + args: parked.args, + interactionToken: parked.interactionToken, + promptPromise: parked.promptPromise, + }, + ], + carriedForward: [], + }, + }, + ); + for ( + let attempt = 0; + attempt < 10 && + !posted.some(({ url }) => + url.endsWith("/sessions/interactions/transition"), + ); + attempt += 1 + ) { + await flush(); + } + + const resolveCall = posted.find(({ url }) => + url.endsWith("/sessions/interactions/transition"), + ); + assert.ok( + resolveCall, + JSON.stringify({ posted, permissionReplies: calls.permissionReplies }), + ); + assert.deepEqual(resolveCall.body, { + session_id: "sess-no-workflow", + token: parked.interactionToken, + status: "resolved", + resolution: { + verdict: "approved", + tool_call_id: "tc-no-workflow", + }, + }); + + captured.onEvent!( + updateEvent({ + sessionUpdate: "tool_call_update", + toolCallId: "tc-no-workflow", + status: "completed", + content: "committed", + }), + ); + calls.resolvePrompt!({ + stopReason: "complete", + usage: { inputTokens: 1, outputTokens: 1 }, + }); + const resumed = await resumedTurn; + assert.equal(resumed.ok, true); + await env.destroy(); + } finally { + fetchSpy.mockRestore(); + } + }); + + it("preserves a denied call failed frame while a sibling gate is carried", async () => { const { calls, deps, captured } = pausableHarness(); const acquired = await acquireEnvironment(engineReq, deps); assert.equal(acquired.ok, true); if (!acquired.ok) return; const env = acquired.env; - const p1 = runTurn(env, engineReq, undefined, undefined, { + const firstTurn = runTurn(env, engineReq, undefined, undefined, { approvalParkMode: true, }); await flush(); + for (const [toolCallId, title] of [["tc-denied", "commit"], ["tc-carried", "deploy"]]) { + captured.onEvent!( + updateEvent({ sessionUpdate: "tool_call", toolCallId, title }), + ); + } captured.onPermissionRequest!({ - id: "perm-1", + id: "perm-denied", availableReplies: ["once", "reject"], - toolCall: { toolCallId: "tc-gate", name: "commit", rawInput: {} }, + toolCall: { toolCallId: "tc-denied", name: "commit", rawInput: {} }, }); - await flush(); - await p1; + captured.onPermissionRequest!({ + id: "perm-carried", + availableReplies: ["once", "reject"], + toolCall: { toolCallId: "tc-carried", name: "deploy", rawInput: {} }, + }); + await firstTurn; + const answered = env.parkedApprovals.get("tc-denied")!; + const carried = env.parkedApprovals.get("tc-carried")!; env.clearTurn(); - const held = env.parkedApproval!.promptPromise!; - const p2 = runTurn(env, approveResume(false), undefined, undefined, { + const resumeTurn = runTurn(env, approveResume(false), undefined, undefined, { approvalParkMode: true, resume: { - permissionId: "perm-1", - reply: "reject", - toolCallId: "tc-gate", - toolName: "commit", - args: {}, - interactionToken: "tc-gate", - promptPromise: held, + decisions: [ + { + permissionId: answered.permissionId, + reply: "reject", + toolCallId: answered.toolCallId, + toolName: answered.toolName, + args: answered.args, + interactionToken: answered.interactionToken, + promptPromise: answered.promptPromise, + }, + ], + carriedForward: [carried], }, }); - await flush(); - calls.resolvePrompt!({ stopReason: "complete" }); - await p2; - - assert.deepEqual(calls.permissionReplies, [ - { id: "perm-1", reply: "reject" }, + for (let i = 0; i < 5 && !env.currentTurn?.pause.active; i += 1) { + await Promise.resolve(); + } + assert.equal(env.currentTurn?.pause.active, true); + captured.onEvent!( + updateEvent({ + sessionUpdate: "tool_call_update", + toolCallId: "tc-denied", + status: "failed", + content: "user denied", + }), + ); + const result = await resumeTurn; + + assert.equal(result.stopReason, "paused"); + const run = calls.runs[1]; + assert.deepEqual(run.denied, ["tc-denied"]); + assert.equal( + run.handled.some( + (update) => + update.toolCallId === "tc-denied" && update.status === "failed", + ), + true, + "the authoritative denial frame must reach the tracer", + ); + assert.equal( + run.settled.some(({ id }) => id === "tc-denied"), + false, + "the deferred sentinel must not replace a denial", + ); + + await env.destroy(); + }); + + it("carries an unanswered gate through a partial resume without settling it", async () => { + const { calls, deps, captured } = pausableHarness(); + const acquired = await acquireEnvironment(engineReq, deps); + assert.equal(acquired.ok, true); + if (!acquired.ok) return; + const env = acquired.env; + + const firstTurn = runTurn(env, engineReq, undefined, undefined, { + approvalParkMode: true, + }); + await flush(); + for (const [toolCallId, title] of [["tc-g1", "commit"], ["tc-g2", "deploy"]]) { + captured.onEvent!( + updateEvent({ sessionUpdate: "tool_call", toolCallId, title }), + ); + } + captured.onPermissionRequest!({ + id: "perm-1", + availableReplies: ["once", "reject"], + toolCall: { toolCallId: "tc-g1", name: "commit", rawInput: {} }, + }); + captured.onPermissionRequest!({ + id: "perm-2", + availableReplies: ["once", "reject"], + toolCall: { toolCallId: "tc-g2", name: "deploy", rawInput: {} }, + }); + await firstTurn; + + const answered = env.parkedApprovals.get("tc-g1")!; + const carried = env.parkedApprovals.get("tc-g2")!; + env.clearTurn(); + const resumeTurn = runTurn(env, approveResume(true), undefined, undefined, { + approvalParkMode: true, + resume: { + decisions: [ + { + permissionId: answered.permissionId, + reply: "once", + toolCallId: answered.toolCallId, + toolName: answered.toolName, + args: answered.args, + interactionToken: answered.interactionToken, + promptPromise: answered.promptPromise, + }, + ], + carriedForward: [carried], + }, + }); + for (let i = 0; i < 5 && !env.currentTurn?.pause.active; i += 1) { + await Promise.resolve(); + } + assert.equal(env.currentTurn?.pause.active, true); + captured.onEvent!( + updateEvent({ + sessionUpdate: "tool_call_update", + toolCallId: "tc-g2", + status: "completed", + content: "must stay suppressed", + }), + ); + captured.onEvent!( + updateEvent({ + sessionUpdate: "tool_call_update", + toolCallId: "tc-g1", + status: "completed", + content: "answered result", + }), + ); + const result = await resumeTurn; + + assert.equal(result.stopReason, "paused"); + assert.deepEqual(calls.permissionReplies, [{ id: "perm-1", reply: "once" }]); + assert.deepEqual([...env.parkedApprovals.keys()], ["tc-g2"]); + assert.equal(env.approvalGateCount, 1); + const run = calls.runs[1]; + assert.equal( + run.handled.some((update) => update.toolCallId === "tc-g2"), + false, + "carried gate frames stay suppressed", + ); + assert.equal( + run.sweeps.every((sweep) => sweep.isExcluded("tc-g2")), + true, + "every sentinel sweep spares the carried gate", + ); + assert.equal( + run.settled.some((entry) => entry.id === "tc-g2"), + false, + "the carried gate receives no sentinel", + ); + + await env.destroy(); + }); + + it("excludes an allowed execution from both pause sweeps", async () => { + const { calls, deps, captured } = pausableHarness(); + const acquired = await acquireEnvironment(engineReq, deps); + assert.equal(acquired.ok, true); + if (!acquired.ok) return; + const env = acquired.env; + + const firstTurn = runTurn(env, engineReq, undefined, undefined, { + approvalParkMode: true, + }); + await flush(); + captured.onEvent!( + updateEvent({ + sessionUpdate: "tool_call", + toolCallId: "tc-approved", + title: "commit", + }), + ); + captured.onPermissionRequest!({ + id: "perm-approved", + availableReplies: ["once", "reject"], + toolCall: { toolCallId: "tc-approved", name: "commit" }, + }); + await firstTurn; + + const held = env.parkedApproval!.promptPromise!; + env.clearTurn(); + const resumeTurn = runTurn(env, approveResume(true), undefined, undefined, { + approvalParkMode: true, + resume: { + decisions: [ + { + permissionId: "perm-approved", + reply: "once", + toolCallId: "tc-approved", + toolName: "commit", + args: {}, + interactionToken: "tc-approved", + promptPromise: held, + }, + ], + carriedForward: [], + }, + }); + await flush(); + captured.onEvent!( + updateEvent({ + sessionUpdate: "tool_call", + toolCallId: "tc-gate-2", + title: "deploy", + }), + ); + captured.onPermissionRequest!({ + id: "perm-2", + availableReplies: ["once", "reject"], + toolCall: { toolCallId: "tc-gate-2", name: "deploy" }, + }); + for (let i = 0; i < 5 && !env.currentTurn?.pause.active; i += 1) { + await Promise.resolve(); + } + assert.equal(env.currentTurn?.pause.active, true); + captured.onEvent!( + updateEvent({ + sessionUpdate: "tool_call_update", + toolCallId: "tc-approved", + status: "in_progress", + }), + ); + captured.onEvent!( + updateEvent({ + sessionUpdate: "tool_call_update", + toolCallId: "tc-approved", + status: "completed", + content: "real result", + }), + ); + await resumeTurn; + + const run = calls.runs[1]; + const deferredSweeps = run.sweeps.filter( + (sweep) => sweep.message === TOOL_NOT_EXECUTED_PAUSED, + ); + assert.ok(deferredSweeps.length >= 2); + assert.equal( + deferredSweeps.every((sweep) => sweep.isExcluded("tc-approved")), + true, + ); + assert.equal( + run.settled.some( + (entry) => + entry.id === "tc-approved" && + entry.message === TOOL_NOT_EXECUTED_PAUSED, + ), + false, + ); + + await env.destroy(); + }); + + it("records an approved completion that arrives after a sibling pause", async () => { + const { deps, captured } = pausableHarness(); + deps.createOtel = createSandboxAgentOtel as any; + const acquired = await acquireEnvironment(engineReq, deps); + assert.equal(acquired.ok, true); + if (!acquired.ok) return; + const env = acquired.env; + + const firstTurn = runTurn(env, engineReq, undefined, undefined, { + approvalParkMode: true, + }); + await flush(); + captured.onEvent!( + updateEvent({ + sessionUpdate: "tool_call", + toolCallId: "tc-approved", + title: "commit", + }), + ); + captured.onPermissionRequest!({ + id: "perm-approved", + availableReplies: ["once", "reject"], + toolCall: { toolCallId: "tc-approved", name: "commit" }, + }); + await firstTurn; + + const held = env.parkedApproval!.promptPromise!; + env.clearTurn(); + const resumeTurn = runTurn(env, approveResume(true), undefined, undefined, { + approvalParkMode: true, + resume: { + decisions: [ + { + permissionId: "perm-approved", + reply: "once", + toolCallId: "tc-approved", + toolName: "commit", + args: {}, + interactionToken: "tc-approved", + promptPromise: held, + }, + ], + carriedForward: [], + }, + }); + await flush(); + captured.onEvent!( + updateEvent({ + sessionUpdate: "tool_call", + toolCallId: "tc-gate-2", + title: "deploy", + }), + ); + captured.onPermissionRequest!({ + id: "perm-2", + availableReplies: ["once", "reject"], + toolCall: { toolCallId: "tc-gate-2", name: "deploy" }, + }); + for (let i = 0; i < 5 && !env.currentTurn?.pause.active; i += 1) { + await Promise.resolve(); + } + assert.equal(env.currentTurn?.pause.active, true); + captured.onEvent!( + updateEvent({ + sessionUpdate: "tool_call_update", + toolCallId: "tc-approved", + status: "completed", + content: "approved call completed", + }), + ); + const result = await resumeTurn; + + assert.equal(result.ok, true); + if (!result.ok) return; + const approvedResults = result.events?.filter( + (event) => event.type === "tool_result" && event.id === "tc-approved", + ); + assert.deepEqual(approvedResults, [ + { + type: "tool_result", + id: "tc-approved", + output: "approved call completed", + isError: false, + }, + ]); + + await env.destroy(); + }); + + it("replays the concurrent-approval incident with deferred closure and exactly-once real results", async () => { + const { calls, deps, captured } = pausableHarness(); + deps.createOtel = createSandboxAgentOtel as any; + const incidentRequest: AgentRunRequest = { + ...engineReq, + permissions: { default: "ask" }, + customTools: [ + { name: "tool-a", permission: "ask" }, + { name: "tool-b", permission: "ask" }, + ], + messages: [{ role: "user", content: "run both writes in parallel" }], + }; + const acquired = await acquireEnvironment(incidentRequest, deps); + assert.equal(acquired.ok, true); + if (!acquired.ok) return; + const env = acquired.env; + + const firstTurn = runTurn( + env, + incidentRequest, + undefined, + undefined, + { approvalParkMode: true }, + ); + await flush(); + captured.onEvent!( + updateEvent({ + sessionUpdate: "tool_call", + toolCallId: "tool-a", + title: "tool-a", + rawInput: { target: "a" }, + }), + ); + captured.onEvent!( + updateEvent({ + sessionUpdate: "tool_call", + toolCallId: "tool-b", + title: "tool-b", + rawInput: { target: "b" }, + }), + ); + captured.onPermissionRequest!({ + id: "permission-a", + availableReplies: ["once", "reject"], + toolCall: { + toolCallId: "tool-a", + name: "tool-a", + rawInput: { target: "a" }, + }, + }); + for (let i = 0; i < 5 && !env.currentTurn?.pause.active; i += 1) { + await Promise.resolve(); + } + assert.equal(env.currentTurn?.pause.active, true); + captured.onEvent!( + updateEvent({ + sessionUpdate: "tool_call_update", + toolCallId: "tool-b", + status: "completed", + content: "tool-b cancellation closure", + }), + ); + const firstResult = await firstTurn; + + assert.equal(firstResult.ok, true); + assert.equal(firstResult.stopReason, "paused"); + assert.equal(env.parkedApprovals.size, 1); + assert.deepEqual([...env.parkedApprovals.keys()], ["tool-a"]); + const gateA = env.parkedApprovals.get("tool-a")!; + const firstEvents = firstResult.events ?? []; + assert.equal( + firstEvents.filter( + (event) => + event.type === "interaction_request" && + (event.payload as { toolCallId?: string }).toolCallId === "tool-a", + ).length, + 1, + ); + assert.deepEqual( + firstEvents.filter( + (event) => event.type === "tool_result" && event.id === "tool-b", + ), + [ + { + type: "tool_result", + id: "tool-b", + output: TOOL_NOT_EXECUTED_PAUSED, + isError: true, + }, + ], + ); + assert.equal( + firstEvents.some( + (event) => event.type === "tool_result" && event.isError === false, + ), + false, + "the never-started first-turn states have no success record", + ); + + env.clearTurn(); + const secondTurn = runTurn( + env, + incidentRequest, + undefined, + undefined, + { + approvalParkMode: true, + resume: { + decisions: [ + { + permissionId: gateA.permissionId, + reply: "once", + toolCallId: gateA.toolCallId, + toolName: gateA.toolName, + args: gateA.args, + interactionToken: gateA.interactionToken, + promptPromise: gateA.promptPromise, + }, + ], + carriedForward: [], + }, + }, + ); + for ( + let i = 0; + i < 5 && + !calls.permissionReplies.some((reply) => reply.id === gateA.permissionId); + i += 1 + ) { + await Promise.resolve(); + } + assert.equal( + calls.permissionReplies.filter((reply) => reply.id === gateA.permissionId) + .length, + 1, + ); + captured.onEvent!( + updateEvent({ + sessionUpdate: "tool_call_update", + toolCallId: "tool-a", + status: "in_progress", + }), + ); + captured.onPermissionRequest!({ + id: "permission-b", + availableReplies: ["once", "reject"], + toolCall: { + toolCallId: "tool-b", + name: "tool-b", + rawInput: { target: "b" }, + }, + }); + for (let i = 0; i < 5 && !env.currentTurn?.pause.active; i += 1) { + await Promise.resolve(); + } + assert.equal(env.currentTurn?.pause.active, true); + captured.onEvent!( + updateEvent({ + sessionUpdate: "tool_call_update", + toolCallId: "tool-a", + status: "completed", + content: "tool-a real output", + }), + ); + const secondResult = await secondTurn; + + assert.equal(secondResult.ok, true); + assert.equal(secondResult.stopReason, "paused"); + assert.equal(env.parkedApprovals.size, 1); + assert.deepEqual([...env.parkedApprovals.keys()], ["tool-b"]); + const gateB = env.parkedApprovals.get("tool-b")!; + const secondEvents = secondResult.events ?? []; + assert.deepEqual( + secondEvents.filter( + (event) => event.type === "tool_result" && event.id === "tool-a", + ), + [ + { + type: "tool_result", + id: "tool-a", + output: "tool-a real output", + isError: false, + }, + ], + ); + assert.equal( + secondEvents.filter( + (event) => + event.type === "interaction_request" && + (event.payload as { toolCallId?: string }).toolCallId === "tool-b", + ).length, + 1, + ); + assert.deepEqual( + secondEvents.filter( + (event) => + event.type === "interaction_response" && + (event.payload as { toolCallId?: string }).toolCallId === "tool-a", + ), + [ + { + type: "interaction_response", + id: gateA.interactionToken, + kind: "user_approval", + payload: { toolCallId: "tool-a", approved: true }, + }, + ], + ); + + env.clearTurn(); + const thirdTurn = runTurn( + env, + incidentRequest, + undefined, + undefined, + { + approvalParkMode: true, + resume: { + decisions: [ + { + permissionId: gateB.permissionId, + reply: "once", + toolCallId: gateB.toolCallId, + toolName: gateB.toolName, + args: gateB.args, + interactionToken: gateB.interactionToken, + promptPromise: gateB.promptPromise, + }, + ], + carriedForward: [], + }, + }, + ); + for ( + let i = 0; + i < 5 && + !calls.permissionReplies.some((reply) => reply.id === gateB.permissionId); + i += 1 + ) { + await Promise.resolve(); + } + assert.equal( + calls.permissionReplies.filter((reply) => reply.id === gateB.permissionId) + .length, + 1, + ); + captured.onEvent!( + updateEvent({ + sessionUpdate: "tool_call_update", + toolCallId: "tool-b", + status: "completed", + content: "tool-b real output", + }), + ); + calls.resolvePrompt!({ + stopReason: "complete", + usage: { inputTokens: 1, outputTokens: 1 }, + }); + const thirdResult = await thirdTurn; + + assert.equal(thirdResult.ok, true); + assert.equal(thirdResult.stopReason, "complete"); + const thirdEvents = thirdResult.events ?? []; + assert.deepEqual( + thirdEvents.filter( + (event) => event.type === "tool_result" && event.id === "tool-b", + ), + [ + { + type: "tool_result", + id: "tool-b", + output: "tool-b real output", + isError: false, + }, + ], + ); + assert.deepEqual( + thirdEvents.filter( + (event) => + event.type === "interaction_response" && + (event.payload as { toolCallId?: string }).toolCallId === "tool-b", + ), + [ + { + type: "interaction_response", + id: gateB.interactionToken, + kind: "user_approval", + payload: { toolCallId: "tool-b", approved: true }, + }, + ], + ); + + const allEvents = [...firstEvents, ...secondEvents, ...thirdEvents]; + const realResults = allEvents.filter( + ( + event, + ): event is Extract => + event.type === "tool_result" && event.isError === false, + ); + assert.deepEqual( + realResults.map((event) => event.id).sort(), + ["tool-a", "tool-b"], + "each call records exactly one real result", + ); + assert.equal( + allEvents.some( + (event) => + event.type === "tool_result" && + event.id === "tool-a" && + (event.output === TOOL_NOT_EXECUTED_PAUSED || + event.output === APPROVED_EXECUTION_RESULT_UNKNOWN), + ), + false, + "the approved tool-a result is never replaced by a sentinel", + ); + assert.equal( + allEvents.some( + (event) => + event.type === "tool_result" && + event.output === "tool-b cancellation closure", + ), + false, + "the cancellation closure is never recorded as success", + ); + const permissionReplyCounts = new Map(); + for (const reply of calls.permissionReplies) { + permissionReplyCounts.set( + reply.id, + (permissionReplyCounts.get(reply.id) ?? 0) + 1, + ); + } + assert.deepEqual( + permissionReplyCounts, + new Map([ + [gateA.permissionId, 1], + [gateB.permissionId, 1], + ]), + ); + + await env.destroy(); + }); + + it("re-parks a Pi batch without a closure wait and records real results after the final approval", async () => { + const batch: PiBatchCall[] = [ + { + permissionId: "permission-a", + toolCallId: "tool-a", + toolName: "tool-a", + args: { target: "a" }, + output: "tool-a real output", + }, + { + permissionId: "permission-b", + toolCallId: "tool-b", + toolName: "tool-b", + args: { target: "b" }, + output: "tool-b real output", + }, + ]; + const { deps } = pausableHarness({ piBatching: batch }); + deps.createOtel = createSandboxAgentOtel as any; + const closureWaitMs = 314_159; + deps.resolveRunLimits = () => ({ + totalMs: 1_000_000, + idleMs: 500_000, + ttfbMs: 500_000, + toolCallMs: closureWaitMs, + }); + deps.createRunLimits = () => ({ + onTrip() {}, + noteToolCallStart() {}, + noteToolCallEnd() {}, + wrapEmit: (emit: (event: any) => void) => emit, + notePaused() {}, + dispose() {}, + }); + const realSetTimeout = globalThis.setTimeout; + let closureWaitCount = 0; + const timeoutSpy = vi.spyOn(globalThis, "setTimeout").mockImplementation( + (( + handler: (...args: any[]) => void, + timeout?: number, + ...args: any[] + ) => { + if (timeout === closureWaitMs) { + closureWaitCount += 1; + return realSetTimeout(handler, 0, ...args); + } + return realSetTimeout(handler, timeout, ...args); + }) as typeof setTimeout, + ); + let env: SessionEnvironment | undefined; + + try { + const piRequest: AgentRunRequest = { + ...engineReq, + harness: "pi_agenta", + permissions: { default: "ask" }, + customTools: batch.map((call) => ({ + name: call.toolName, + permission: "ask", + })), + messages: [{ role: "user", content: "run both writes in parallel" }], + }; + const acquired = await acquireEnvironment(piRequest, deps); + assert.equal(acquired.ok, true); + if (!acquired.ok) return; + env = acquired.env; + + const firstResult = await runTurn( + env, + piRequest, + undefined, + undefined, + { approvalParkMode: true }, + ); + assert.equal(firstResult.stopReason, "paused"); + const gateA = env.parkedApprovals.get("tool-a")!; + + env.clearTurn(); + const secondResult = await runTurn( + env, + piRequest, + undefined, + undefined, + { + approvalParkMode: true, + resume: { + decisions: [ + { + permissionId: gateA.permissionId, + reply: "once", + toolCallId: gateA.toolCallId, + toolName: gateA.toolName, + args: gateA.args, + interactionToken: gateA.interactionToken, + promptPromise: gateA.promptPromise, + }, + ], + carriedForward: [], + }, + }, + ); + assert.equal(secondResult.stopReason, "paused"); + assert.equal( + closureWaitCount, + 0, + "the pending Pi sibling must bypass the bounded closure wait", + ); + assert.deepEqual( + (secondResult.events ?? []).filter( + (event) => event.type === "tool_result", + ), + [ + { + type: "tool_result", + id: "tool-a", + output: APPROVED_EXECUTION_RESULT_UNKNOWN, + isError: true, + }, + ], + ); + assert.deepEqual( + [...(env.parkedApprovedExecutions?.keys() ?? [])], + ["tool-a"], + ); + const gateB = env.parkedApprovals.get("tool-b")!; + + env.clearTurn(); + const thirdResult = await runTurn( + env, + piRequest, + undefined, + undefined, + { + approvalParkMode: true, + resume: { + decisions: [ + { + permissionId: gateB.permissionId, + reply: "once", + toolCallId: gateB.toolCallId, + toolName: gateB.toolName, + args: gateB.args, + interactionToken: gateB.interactionToken, + promptPromise: gateB.promptPromise, + }, + ], + carriedForward: [], + }, + }, + ); + assert.equal(thirdResult.stopReason, "complete"); + + const eventLog = [ + ...(firstResult.events ?? []), + ...(secondResult.events ?? []), + ...(thirdResult.events ?? []), + ]; + const realResults = eventLog.filter( + ( + event, + ): event is Extract => + event.type === "tool_result" && + (event.output === "tool-a real output" || + event.output === "tool-b real output"), + ); + assert.deepEqual( + realResults.map((event) => event.id).sort(), + ["tool-a", "tool-b"], + "both batched calls record one real result", + ); + const lastResultByCall = new Map< + string, + Extract + >(); + for (const event of eventLog) { + if (event.type === "tool_result" && event.id) { + lastResultByCall.set(event.id, event); + } + } + assert.equal(lastResultByCall.get("tool-a")?.output, "tool-a real output"); + assert.equal(lastResultByCall.get("tool-a")?.isError, false); + assert.equal(lastResultByCall.get("tool-b")?.output, "tool-b real output"); + assert.equal(lastResultByCall.get("tool-b")?.isError, false); + assert.equal(env.parkedApprovedExecutions?.size, 0); + } finally { + timeoutSpy.mockRestore(); + if (env) await env.destroy(); + } + }); + + it("records the non-retry sentinel when an approved result misses the bound", async () => { + const { calls, deps, captured } = pausableHarness(); + deps.resolveRunLimits = () => ({ + totalMs: 1_000, + idleMs: 500, + ttfbMs: 500, + toolCallMs: 5, + }); + const acquired = await acquireEnvironment(engineReq, deps); + assert.equal(acquired.ok, true); + if (!acquired.ok) return; + const env = acquired.env; + + const firstTurn = runTurn(env, engineReq, undefined, undefined, { + approvalParkMode: true, + }); + await flush(); + captured.onEvent!( + updateEvent({ + sessionUpdate: "tool_call", + toolCallId: "tc-approved", + title: "commit", + }), + ); + captured.onPermissionRequest!({ + id: "perm-approved", + availableReplies: ["once", "reject"], + toolCall: { toolCallId: "tc-approved", name: "commit" }, + }); + await firstTurn; + + const held = env.parkedApproval!.promptPromise!; + env.clearTurn(); + const resumeTurn = runTurn(env, approveResume(true), undefined, undefined, { + approvalParkMode: true, + resume: { + decisions: [ + { + permissionId: "perm-approved", + reply: "once", + toolCallId: "tc-approved", + toolName: "commit", + args: {}, + interactionToken: "tc-approved", + promptPromise: held, + }, + ], + carriedForward: [], + }, + }); + await flush(); + captured.onEvent!( + updateEvent({ + sessionUpdate: "tool_call", + toolCallId: "tc-gate-2", + title: "deploy", + }), + ); + captured.onPermissionRequest!({ + id: "perm-2", + availableReplies: ["once", "reject"], + toolCall: { toolCallId: "tc-gate-2", name: "deploy" }, + }); + const result = await resumeTurn; + + assert.equal(result.ok, true); + assert.deepEqual( + calls.runs[1].settled.filter((entry) => entry.id === "tc-approved"), + [ + { + id: "tc-approved", + message: APPROVED_EXECUTION_RESULT_UNKNOWN, + }, + ], + ); + assert.equal( + APPROVED_EXECUTION_RESULT_UNKNOWN.startsWith( + DEFERRED_NOT_EXECUTED_PREFIX, + ), + false, + ); + + await env.destroy(); + }); + + it("forwards a reject on the resume when the decision is deny", async () => { + const { calls, deps, captured } = pausableHarness(); + const acquired = await acquireEnvironment(engineReq, deps); + assert.equal(acquired.ok, true); + if (!acquired.ok) return; + const env = acquired.env; + + const p1 = runTurn(env, engineReq, undefined, undefined, { + approvalParkMode: true, + }); + await flush(); + captured.onPermissionRequest!({ + id: "perm-1", + availableReplies: ["once", "reject"], + toolCall: { toolCallId: "tc-gate", name: "commit", rawInput: {} }, + }); + await flush(); + await p1; + + env.clearTurn(); + const held = env.parkedApproval!.promptPromise!; + const p2 = runTurn(env, approveResume(false), undefined, undefined, { + approvalParkMode: true, + resume: { + decisions: [ + { + permissionId: "perm-1", + reply: "reject", + toolCallId: "tc-gate", + toolName: "commit", + args: {}, + interactionToken: "tc-gate", + promptPromise: held, + }, + ], + carriedForward: [], + }, + }); + await flush(); + calls.resolvePrompt!({ stopReason: "complete" }); + await p2; + + assert.deepEqual(calls.permissionReplies, [ + { id: "perm-1", reply: "reject" }, ]); + assert.deepEqual( + calls.runs[1].emitted.filter( + (event) => event.type === "interaction_response", + ), + [ + { + type: "interaction_response", + id: "tc-gate", + kind: "user_approval", + payload: { toolCallId: "tc-gate", approved: false }, + }, + ], + ); await env.destroy(); }); @@ -1263,8 +2822,9 @@ describe("runTurn: real approval park + respondPermission resume", () => { approvalParkMode: true, }); await flush(); - // A latch-loser sibling tool call announced BEFORE the winning gate: it can never execute - // this turn and must be settled with the deterministic paused result, park or no park. + // An announced-but-UNGATED sibling tool call (it never raises a permission request, so it gets + // no card): it can never execute once the turn pauses on the gate, so it must be settled with + // the deterministic paused result, park or no park. captured.onEvent!( updateEvent({ sessionUpdate: "tool_call", @@ -1310,7 +2870,7 @@ describe("runTurn: real approval park + respondPermission resume", () => { await env.destroy(); }); - it("the sibling settle also covers the multi-gate case the dispatch then destroys", async () => { + it("two parallel gates each emit a card and BOTH park (neither is force-settled)", async () => { const { calls, deps, captured } = pausableHarness(); const acquired = await acquireEnvironment(engineReq, deps); assert.equal(acquired.ok, true); @@ -1321,9 +2881,9 @@ describe("runTurn: real approval park + respondPermission resume", () => { approvalParkMode: true, }); await flush(); - // Two parallel gates: gate 1 wins the latch and pauses; at pause time gate 2's call is an - // open non-paused sibling, so the UNCONDITIONAL settle covers it even though parkedApproval - // is already set (the exact case an early-return-before-settle would orphan). + // Two parallel gated tool calls in one turn. With no latch, each raises its own permission + // request, emits its own card, and is marked paused — so NEITHER is force-settled, and BOTH + // are recorded in parkedApprovals for the live resume. captured.onEvent!( updateEvent({ sessionUpdate: "tool_call", @@ -1353,18 +2913,31 @@ describe("runTurn: real approval park + respondPermission resume", () => { assert.equal(r1.stopReason, "paused"); assert.equal(env.approvalGateCount, 2, "both pending gates were counted"); + assert.equal(env.parkedApprovals.size, 2, "both gates were parked"); + assert.deepEqual( + [...env.parkedApprovals.keys()].sort(), + ["tc-g1", "tc-g2"], + "each gate is keyed by its own tool-call id", + ); + assert.equal( + env.nonParkablePauseCount, + 0, + "no non-parkable pause -> the dispatch parks the whole set", + ); const run1 = calls.runs[0]; assert.deepEqual( run1.settled, - [{ id: "tc-g2", message: TOOL_NOT_EXECUTED_PAUSED }], - "the second gate's call settled as a sibling at pause time", + [], + "neither gate is force-settled: both got a card and are held for the resume", + ); + assert.ok(run1.open.has("tc-g1"), "the first gate's call stays open"); + assert.ok(run1.open.has("tc-g2"), "the second gate's call stays open"); + assert.equal( + calls.sessionDestroyed, + 0, + "the multi-gate park keeps the session alive", ); - assert.ok(run1.open.has("tc-g1"), "the winning gate's call stays open"); - // The dispatch refuses to park a multi-gate pause and destroys: the teardown the park - // skipped (destroySession, sandbox teardown) runs through env.destroy(). await env.destroy(); - assert.equal(calls.sessionDestroyed, 1, "destroy tore the session down"); - assert.equal(calls.sandboxDestroyed, 1); }); }); diff --git a/services/runner/tests/unit/session-keepalive-dispatch.test.ts b/services/runner/tests/unit/session-keepalive-dispatch.test.ts index 74c40f4f40..04129c0bc4 100644 --- a/services/runner/tests/unit/session-keepalive-dispatch.test.ts +++ b/services/runner/tests/unit/session-keepalive-dispatch.test.ts @@ -51,6 +51,9 @@ interface FakeEnv { destroyed: number; turnsCleared: number; lastTurnToolCallIds: string[]; + parkedApprovals: Map; + approvalGateCount: number; + nonParkablePauseCount: number; clearTurn: () => void; /** Replaceable body so a test can slow the teardown down; `destroy` stays a stable closure. */ destroyImpl: () => Promise; @@ -78,6 +81,9 @@ function makeEngine(options: EngineOptions = {}) { destroyed: 0, turnsCleared: 0, lastTurnToolCallIds: [], + parkedApprovals: new Map(), + approvalGateCount: 0, + nonParkablePauseCount: 0, clearTurn: () => { env.turnsCleared += 1; }, @@ -210,6 +216,23 @@ function turn2( } describe("runWithKeepalive: park + hit", () => { + it("mints an execution turnId when the request omits one, before the engine runs", async () => { + const made = makeEngine({}); + const seen: Array = []; + const inner = made.engine.runTurn; + made.engine.runTurn = async (env, req, emit, signal, opts) => { + seen.push(req.turnId); + return inner(env, req, emit, signal, opts); + }; + const req = turn1("turnid-session"); + delete (req as Record)["turnId"]; + await runWithKeepalive(req, undefined, undefined, makeCtx(made.engine)); + assert.equal(seen.length, 1); + // The ledger append and interaction rows read request.turnId; a run without one would + // write NULL execution ids (the live-verification defect this pins). + assert.ok(typeof seen[0] === "string" && seen[0].length > 0); + }); + it("calls the live-park hook once for Daytona, never for local", async () => { const daytona = makeEngine({ onParkedLive: true }); const daytonaContext = makeCtx(daytona.engine); diff --git a/services/runner/tests/unit/session-keepalive-engine.test.ts b/services/runner/tests/unit/session-keepalive-engine.test.ts index 28e26decf0..907b25cadb 100644 --- a/services/runner/tests/unit/session-keepalive-engine.test.ts +++ b/services/runner/tests/unit/session-keepalive-engine.test.ts @@ -19,10 +19,12 @@ import { runTurn, type SandboxAgentDeps, } from "../../src/engines/sandbox_agent.ts"; +import { SessionContinuityStore } from "../../src/engines/sandbox_agent/session-continuity.ts"; interface FakeOptions { probeError?: Error; createOtelError?: Error; + stopReasons?: string[]; } /** One fake otel run per createOtel call, recording which updates it handled. */ @@ -40,9 +42,14 @@ function fakeHarness(options: FakeOptions = {}) { sandboxDisposed: 0, sessionDestroyed: 0, permissionReplies: [] as Array<{ id: string; reply: string }>, + startedTurnIndexes: [] as number[], + completedTurnIndexes: [] as number[], + startedTurns: [] as Array>, + ledgerRows: new Map(), logs: [] as string[], runs: [] as FakeRun[], }; + const continuityStore = new SessionContinuityStore(); // Captured session-lifetime handlers, so tests can fire events/permissions at any moment // (during a turn, between turns). @@ -53,6 +60,7 @@ function fakeHarness(options: FakeOptions = {}) { const session = { id: "session-1", + agentSessionId: "agent-session-1", onEvent(handler: (event: any) => void) { captured.onEvent = handler; }, @@ -65,7 +73,7 @@ function fakeHarness(options: FakeOptions = {}) { async prompt(_blocks: any) { calls.promptCount += 1; return { - stopReason: "complete", + stopReason: options.stopReasons?.[calls.promptCount - 1] ?? "complete", usage: { inputTokens: 1, outputTokens: 1 }, }; }, @@ -116,13 +124,28 @@ function fakeHarness(options: FakeOptions = {}) { }, settleOpenToolCalls() {}, traceId() { - return "trace-1"; + return "0123456789abcdef0123456789abcdef"; }, }; calls.runs.push(run); return run; }; + const sessionTurnClient: NonNullable< + SandboxAgentDeps["appendSessionTurn"] + > = async (_sessionId, _harness, turnIndex, turn) => { + calls.startedTurnIndexes.push(turnIndex); + calls.startedTurns.push({ ...turn }); + if (!calls.ledgerRows.has(turnIndex)) { + calls.ledgerRows.set(turnIndex, { startTime: turn.startTime }); + } + }; + sessionTurnClient.complete = async (_sessionId, turnIndex, turn) => { + calls.completedTurnIndexes.push(turnIndex); + const row = calls.ledgerRows.get(turnIndex); + if (row && !row.endTime) row.endTime = turn.endTime; + }; + const deps: SandboxAgentDeps = { log: (message) => calls.logs.push(message), createLocalCwd: (durable?: string) => durable ?? "/tmp/agenta-fake-cwd", @@ -163,6 +186,9 @@ function fakeHarness(options: FakeOptions = {}) { return { kind: "deny" } as const; }, }), + sessionContinuityStore: continuityStore, + hydrateHarnessSessionFromDurable: async () => {}, + appendSessionTurn: sessionTurnClient, }; return { calls, deps, captured }; @@ -173,6 +199,22 @@ const request: AgentRunRequest = { messages: [{ role: "user", content: "hello" }], }; +const continuityRequest: AgentRunRequest = { + ...request, + sessionId: "sess-1", + streamId: "stream-1", + turnId: "turn-execution-1", + runContext: { + trace: { + trace_id: "0123456789abcdef0123456789abcdef", + span_id: "a1b2c3d4e5f6a7b8", + }, + }, + telemetry: { + exporters: { otlp: { headers: { authorization: "ApiKey abc" } } }, + } as any, +}; + function updateEvent(update: Record) { return { payload: { update } }; } @@ -282,6 +324,104 @@ describe("acquireEnvironment / runTurn split", () => { }); }); +describe("conversation turn indexes", () => { + it("advances on every completed turn served by the same warm environment", async () => { + const { calls, deps } = fakeHarness(); + const acquired = await acquireEnvironment(continuityRequest, deps); + assert.equal(acquired.ok, true); + if (!acquired.ok) return; + + for (let turn = 0; turn < 4; turn += 1) { + const result = await runTurn( + acquired.env, + { + ...continuityRequest, + messages: [{ role: "user", content: "turn " + turn }], + }, + undefined, + undefined, + { continuation: turn > 0 }, + ); + assert.equal(result.ok, true); + } + + assert.deepEqual(calls.startedTurnIndexes, [0, 1, 2, 3]); + assert.deepEqual(calls.completedTurnIndexes, [0, 1, 2, 3]); + await acquired.env.destroy(); + }); + + it("shares strictly increasing indexes across two environments for one session", async () => { + const { calls, deps } = fakeHarness({ + stopReasons: ["paused", "complete", "complete", "complete"], + }); + const first = await acquireEnvironment(continuityRequest, deps); + const second = await acquireEnvironment(continuityRequest, deps); + assert.equal(first.ok, true); + assert.equal(second.ok, true); + if (!first.ok || !second.ok) return; + + const parked = await runTurn( + first.env, + continuityRequest, + undefined, + undefined, + {}, + ); + assert.equal(parked.stopReason, "paused"); + await runTurn(second.env, continuityRequest, undefined, undefined, {}); + await runTurn(first.env, continuityRequest, undefined, undefined, { + continuation: true, + }); + await runTurn(second.env, continuityRequest, undefined, undefined, { + continuation: true, + }); + + assert.deepEqual(calls.startedTurnIndexes, [0, 0, 1, 2]); + assert.deepEqual(calls.completedTurnIndexes, [0, 1, 2]); + await first.env.destroy(); + await second.env.destroy(); + }); + + it("starts a paused turn once across resume and completes that ledger row", async () => { + const { calls, deps } = fakeHarness({ + stopReasons: ["paused", "complete"], + }); + const acquired = await acquireEnvironment(continuityRequest, deps); + assert.equal(acquired.ok, true); + if (!acquired.ok) return; + + const parked = await runTurn( + acquired.env, + continuityRequest, + undefined, + undefined, + {}, + ); + assert.equal(parked.stopReason, "paused"); + assert.deepEqual(calls.startedTurnIndexes, [0]); + assert.deepEqual(calls.completedTurnIndexes, []); + assert.equal(calls.ledgerRows.size, 1); + assert.equal(typeof calls.startedTurns[0]["startTime"], "string"); + assert.equal(calls.startedTurns[0]["traceId"], "0123456789abcdef0123456789abcdef"); + assert.equal(calls.startedTurns[0]["spanId"], "a1b2c3d4e5f6a7b8"); + assert.equal(calls.startedTurns[0]["turnId"], "turn-execution-1"); + + const resumed = await runTurn( + acquired.env, + continuityRequest, + undefined, + undefined, + { continuation: true }, + ); + assert.equal(resumed.ok, true); + assert.deepEqual(calls.startedTurnIndexes, [0, 0]); + assert.deepEqual(calls.completedTurnIndexes, [0]); + assert.equal(calls.ledgerRows.size, 1); + assert.equal(typeof calls.ledgerRows.get(0)?.endTime, "string"); + await acquired.env.destroy(); + }); +}); + describe("session-lifetime listener demux (currentTurn swap)", () => { it("each turn's sink sees only its own events; between-turns events are dropped by decision", async () => { const { calls, deps, captured } = fakeHarness(); diff --git a/services/runner/tests/unit/session-persist.test.ts b/services/runner/tests/unit/session-persist.test.ts index 5db1789fc7..449f021b90 100644 --- a/services/runner/tests/unit/session-persist.test.ts +++ b/services/runner/tests/unit/session-persist.test.ts @@ -25,10 +25,8 @@ vi.stubGlobal("fetch", async (_url: string, init?: RequestInit) => { return new Response(JSON.stringify({ ok: true }), { status: 200 }); }); -const { - buildPersistingEmitter, - drainPersist, -} = await import("../../src/sessions/persist.ts"); +const { buildPersistingEmitter, drainPersist } = + await import("../../src/sessions/persist.ts"); beforeEach(() => { postedBodies.length = 0; @@ -89,8 +87,8 @@ describe("buildPersistingEmitter", () => { const live: unknown[] = []; const { emit, flush } = buildPersistingEmitter( "sess-2", - () => "Secret t", (e) => - live.push(e), + () => "Secret t", + (e) => live.push(e), ); emit({ type: "message_start", id: "m1" }); @@ -123,6 +121,70 @@ describe("buildPersistingEmitter", () => { assert.deepEqual(types, ["tool_call", "done"]); }); + it("scopes stable tool ids by execution while re-persisting reuses the row", async () => { + const first = buildPersistingEmitter( + "sess-shared-tool", + () => "Secret t", + undefined, + undefined, + "turn-a", + ); + first.emit({ type: "tool_result", id: "call-shared", output: "ok" }); + first.emit({ type: "tool_result", id: "call-shared", output: "ok" }); + await first.flush(); + + const second = buildPersistingEmitter( + "sess-shared-tool", + () => "Secret t", + undefined, + undefined, + "turn-b", + ); + second.emit({ type: "tool_result", id: "call-shared", output: "ok" }); + await second.flush(); + + const bodies = postedBodies as Array>; + assert.equal(bodies.length, 3); + assert.equal(bodies[0]["record_id"], bodies[1]["record_id"]); + assert.notEqual(bodies[0]["record_id"], bodies[2]["record_id"]); + assert.deepEqual( + bodies.map((body) => body["turn_id"]), + ["turn-a", "turn-a", "turn-b"], + ); + }); + + it("gives interaction responses a retry-stable id distinct from the request", async () => { + const { emit, flush } = buildPersistingEmitter( + "sess-interaction", + () => "Secret t", + ); + + emit({ + type: "interaction_request", + id: "approval-1", + kind: "user_approval", + }); + emit({ + type: "interaction_response", + id: "approval-1", + kind: "user_approval", + payload: { toolCallId: "tool-1", approved: true }, + }); + emit({ + type: "interaction_response", + id: "approval-1", + kind: "user_approval", + payload: { toolCallId: "tool-1", approved: true }, + }); + await flush(); + + const bodies = postedBodies as Array>; + assert.ok(typeof bodies[0]["record_id"] === "string"); + assert.ok(typeof bodies[1]["record_id"] === "string"); + assert.notEqual(bodies[0]["record_id"], bodies[1]["record_id"]); + assert.equal(bodies[1]["record_id"], bodies[2]["record_id"]); + }); + it("record_index increments monotonically across events", async () => { const { emit, flush } = buildPersistingEmitter("sess-4", () => "Secret t"); @@ -147,8 +209,18 @@ describe("buildPersistingEmitter", () => { // A tool call streams a growing partial-args snapshot for one id. emit({ type: "tool_call", id: "call_1", name: "bash", input: {} }); - emit({ type: "tool_call", id: "call_1", name: "bash", input: { command: "fi" } }); - emit({ type: "tool_call", id: "call_1", name: "bash", input: { command: "find ." } }); + emit({ + type: "tool_call", + id: "call_1", + name: "bash", + input: { command: "fi" }, + }); + emit({ + type: "tool_call", + id: "call_1", + name: "bash", + input: { command: "find ." }, + }); // A different step closes the open call. emit({ type: "tool_result", id: "call_1", output: "ok" }); emit({ type: "done" }); @@ -173,10 +245,23 @@ describe("buildPersistingEmitter", () => { }); it("a different tool id flushes the previous open call", async () => { - const { emit, flush } = buildPersistingEmitter("sess-tc2", () => "Secret t"); + const { emit, flush } = buildPersistingEmitter( + "sess-tc2", + () => "Secret t", + ); - emit({ type: "tool_call", id: "call_a", name: "read", input: { path: "/x" } }); - emit({ type: "tool_call", id: "call_b", name: "read", input: { path: "/y" } }); + emit({ + type: "tool_call", + id: "call_a", + name: "read", + input: { path: "/x" }, + }); + emit({ + type: "tool_call", + id: "call_b", + name: "read", + input: { path: "/y" }, + }); await flush(); const bodies = postedBodies as Array>; @@ -184,14 +269,25 @@ describe("buildPersistingEmitter", () => { (b) => (b["attributes"] as Record)["input"], ); assert.deepEqual(inputs, [{ path: "/x" }, { path: "/y" }]); - assert.deepEqual(bodies.map((b) => b["record_index"]), [0, 1]); + assert.deepEqual( + bodies.map((b) => b["record_index"]), + [0, 1], + ); }); it("flushes an open (paused) tool_call on drain", async () => { - const { emit, flush } = buildPersistingEmitter("sess-tc3", () => "Secret t"); + const { emit, flush } = buildPersistingEmitter( + "sess-tc3", + () => "Secret t", + ); // A paused call ends the turn with its slot still open. - emit({ type: "tool_call", id: "call_p", name: "bash", input: { command: "ls" } }); + emit({ + type: "tool_call", + id: "call_p", + name: "bash", + input: { command: "ls" }, + }); await flush(); const bodies = postedBodies as Array>; @@ -205,15 +301,28 @@ describe("buildPersistingEmitter", () => { it("flushes an open tool_call when the idle TTL fires", async () => { vi.useFakeTimers(); try { - const { emit, flush } = buildPersistingEmitter("sess-tc4", () => "Secret t"); + const { emit, flush } = buildPersistingEmitter( + "sess-tc4", + () => "Secret t", + ); - emit({ type: "tool_call", id: "call_ttl", name: "bash", input: { command: "x" } }); + emit({ + type: "tool_call", + id: "call_ttl", + name: "bash", + input: { command: "x" }, + }); // Nothing follows; only the TTL can close it. assert.equal(postedBodies.length, 0); await vi.advanceTimersByTimeAsync(3000); assert.equal(postedBodies.length, 1); assert.equal( - ((postedBodies[0] as Record)["attributes"] as Record)["type"], + ( + (postedBodies[0] as Record)["attributes"] as Record< + string, + unknown + > + )["type"], "tool_call", ); await flush(); @@ -223,13 +332,150 @@ describe("buildPersistingEmitter", () => { }); }); +describe("buildPersistingEmitter turn/span tagging", () => { + it("stamps turn_id and span_id on every posted record when both are supplied", async () => { + // A real 16-hex OTel span id (the runContext.trace.span_id shape), NOT a UUID — the API + // types this field as a 16-hex string, so anything else 422s (FINDING-record-ingest-422). + const spanId = "a1b2c3d4e5f6a7b8"; + const { emit, persist, flush } = buildPersistingEmitter( + "sess-turn", + () => "Secret t", + undefined, + undefined, + "turn-abc", + spanId, + ); + + persist({ type: "message", text: "hi" }, "user"); + emit({ type: "message", text: "hello" }); + emit({ type: "done" }); + await flush(); + + const bodies = postedBodies as Array>; + assert.equal(bodies.length, 3); + for (const body of bodies) { + assert.equal(body["turn_id"], "turn-abc"); + assert.equal(body["span_id"], spanId); + } + }); + + it("omits turn_id/span_id from the body when neither is supplied", async () => { + const { emit, flush } = buildPersistingEmitter( + "sess-noturn", + () => "Secret t", + ); + + emit({ type: "message", text: "hello" }); + await flush(); + + const body = postedBodies[0] as Record; + assert.equal("turn_id" in body, false); + assert.equal("span_id" in body, false); + }); + + it("stamps turn_id on coalesced and tool-call records too", async () => { + const { emit, flush } = buildPersistingEmitter( + "sess-turn-tc", + () => "Secret t", + undefined, + undefined, + "turn-tc", + ); + + emit({ type: "message_start", id: "m1" }); + emit({ type: "message_delta", id: "m1", delta: "hi" }); + emit({ type: "message_end", id: "m1" }); + emit({ + type: "tool_call", + id: "call_1", + name: "bash", + input: { command: "ls" }, + }); + emit({ type: "tool_result", id: "call_1", output: "ok" }); + await flush(); + + const bodies = postedBodies as Array>; + assert.equal(bodies.length, 3); + for (const body of bodies) { + assert.equal(body["turn_id"], "turn-tc"); + assert.equal("span_id" in body, false); + } + }); +}); + +describe("buildPersistingEmitter API contract", () => { + // The API's SessionRecordIngestRequest types span_id as a 16-hex OTel span id. A stub that + // returns 200 for ANY body (like the other tests here) hid the real bug: the field used to + // be typed as UUID and every ingest 422'd. This stub enforces the real contract, so a + // regression to a non-span-shaped span_id fails here instead of silently dropping records. + const OTEL_SPAN_ID = /^[0-9a-fA-F]{16}$/; + + it("emits a span_id the API contract accepts (16-hex OTel span id, not a UUID)", async () => { + const accepted: Array> = []; + const rejected: Array> = []; + const contractFetch = (async (_url: string, init?: RequestInit) => { + const body = init?.body + ? (JSON.parse(init.body as string) as Record) + : {}; + if ( + body.span_id !== undefined && + !OTEL_SPAN_ID.test(String(body.span_id)) + ) { + rejected.push(body); + return new Response(JSON.stringify({ detail: "uuid_parsing" }), { + status: 422, + }); + } + accepted.push(body); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }) as typeof fetch; + + const prior = globalThis.fetch; + globalThis.fetch = contractFetch; + try { + const spanId = "a1b2c3d4e5f6a7b8"; // the runContext.trace.span_id shape + const { emit, persist, flush } = buildPersistingEmitter( + "sess-contract", + () => "Secret t", + undefined, + undefined, + "turn-1", + spanId, + ); + persist({ type: "message", text: "hi" }, "user"); + emit({ type: "message", text: "hello" }); + emit({ type: "done" }); + await flush(); + + // Every record cleared the contract-validating stub; nothing 422'd or dropped. + assert.equal(rejected.length, 0); + assert.equal(accepted.length, 3); + for (const body of accepted) + assert.match(String(body.span_id), OTEL_SPAN_ID); + } finally { + globalThis.fetch = prior; + } + }); + + it("the contract stub has teeth: a non-span-shaped span_id would 422", () => { + // Guards the test above: the old mock returned 200 for any body, so a UUID (or any + // arbitrary string) sailed through. The real contract rejects both. + assert.equal(OTEL_SPAN_ID.test("a1b2c3d4e5f6a7b8"), true); + assert.equal(OTEL_SPAN_ID.test("span-def"), false); + assert.equal(OTEL_SPAN_ID.test("f".repeat(32)), false); // a UUID (32 hex) is not a span id + }); +}); + describe("drainPersist", () => { it("resolves immediately when no events are pending", async () => { await assert.doesNotReject(() => drainPersist("no-session")); }); it("waits for all queued events before resolving", async () => { - const { emit, flush } = buildPersistingEmitter("sess-drain", () => "Secret t"); + const { emit, flush } = buildPersistingEmitter( + "sess-drain", + () => "Secret t", + ); emit({ type: "message", text: "x" }); emit({ type: "done" }); await flush(); // same as drainPersist internally diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index 14b0753ed4..f38df11416 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -28,6 +28,7 @@ import { buildAgentRequest, buildTurnCapture, playgroundController, + type LiveAgentInteraction, } from "@agenta/playground" import {agentSelfCommitSignalAtom, simulatedAgentRunAtomFamily} from "@agenta/shared/state" import {generateId} from "@agenta/shared/utils" @@ -570,6 +571,8 @@ const AgentConversation = ({ const revalidateSessionMounts = useSetAtom(revalidateSessionMountsAtom) const revalidateSessionRecords = useSetAtom(revalidateSessionRecordsAtom) + // Only a gate settled in this mount may trigger an automatic resume; hydrated answers stay inert. + const liveGateInteractionRef = useRef(null) const { messages, @@ -590,7 +593,14 @@ const AgentConversation = ({ experimental_throttle: 50, // Approve AND deny both resume — a deny-only decision must re-send so the runner // gets the denial round-trip and the model continues (no `approval-responded` limbo). - sendAutomaticallyWhen: agentShouldResumeAfterApproval, + sendAutomaticallyWhen: ({messages}) => { + const shouldDispatch = agentShouldResumeAfterApproval({ + messages, + liveInteraction: liveGateInteractionRef.current, + }) + if (shouldDispatch) liveGateInteractionRef.current = null + return shouldDispatch + }, // The turn's trace may not be ingested yet when the row asks for its summary — // marking it fresh lets the trace queries retry through the ingestion lag // (historical traces get no such grace; a 404 there means the trace is gone). @@ -697,20 +707,13 @@ const AgentConversation = ({ // Seed once per mounted session tab; `sessionId` is stable for this instance. }, [sessionId]) - // True once the user settles a gate (approval response / client-tool output) in THIS mount — - // i.e. the SDK's auto-resume genuinely is imminent, so the queue's pre-resume hold applies. - // Without a live interaction, a tail that READS as "resume imminent" is an orphan restored - // from storage (reload / remount killed the run mid-resume): nothing will ever fire that - // resume, and holding for it froze the composer forever (AGE-3937). - const liveGateInteractionRef = useRef(false) - // Settle a parked client tool (#4920). The dispatcher calls this from a widget (e.g. the connect // widget) with the structured reference; `addToolOutput` matches the part by `toolCallId` on the // last turn and the resume predicate auto-resends. `tool` is only the typed-tools key — matching // is by id — so a cast onto the untyped UIMessage tool map is safe. const handleClientToolOutput = useCallback( ({toolName, toolCallId, output, errorText}) => { - liveGateInteractionRef.current = true + liveGateInteractionRef.current = {kind: "client_tool", id: toolCallId} if (errorText !== undefined) { addToolOutput({ state: "output-error", @@ -1068,7 +1071,7 @@ const AgentConversation = ({ // after a reload genuinely auto-resumes, so the queue's pre-resume hold must apply to it. const handleApprovalResponse = useCallback( (args: {id: string; approved: boolean}) => { - liveGateInteractionRef.current = true + liveGateInteractionRef.current = {kind: "approval", id: args.id} addToolApprovalResponse(args) }, [addToolApprovalResponse], diff --git a/web/oss/src/components/AgentChatSlice/assets/transcriptToMessages.test.ts b/web/oss/src/components/AgentChatSlice/assets/transcriptToMessages.test.ts new file mode 100644 index 0000000000..840b0a2da5 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/assets/transcriptToMessages.test.ts @@ -0,0 +1,218 @@ +import type {SessionRecord} from "@agenta/entities/session" +import {describe, expect, it} from "vitest" + +import {APPROVED_EXECUTION_RESULT_UNKNOWN, transcriptToMessages} from "./transcriptToMessages" + +const record = (id: string, payload: Record, sender = "agent"): SessionRecord => ({ + id, + session_id: "session-1", + project_id: "project-1", + event_index: null, + sender, + session_update: String(payload.type), + payload, + created_at: null, +}) + +const approvalRecords = (): SessionRecord[] => [ + record("record-call", { + type: "tool_call", + id: "tool-1", + name: "bash", + input: {command: "ls"}, + }), + record("record-request", { + type: "interaction_request", + id: "approval-1", + kind: "user_approval", + payload: {toolCallId: "tool-1"}, + }), +] + +const firstPart = (records: SessionRecord[]): Record => { + const messages = transcriptToMessages(records) + expect(messages).not.toBeNull() + return messages?.[0].parts[0] as unknown as Record +} + +describe("transcriptToMessages approval hydration", () => { + it("overlays a persisted approval response with the live response shape", () => { + const part = firstPart([ + ...approvalRecords(), + record("record-response", { + type: "interaction_response", + id: "approval-1", + kind: "user_approval", + payload: {toolCallId: "tool-1", approved: true}, + }), + ]) + + expect(part).toEqual({ + type: "tool-bash", + toolCallId: "tool-1", + state: "approval-responded", + input: {command: "ls"}, + approval: {id: "approval-1", approved: true}, + }) + }) + + it("keeps an unanswered request pending", () => { + const part = firstPart(approvalRecords()) + + expect(part.state).toBe("approval-requested") + expect(part.approval).toEqual({id: "approval-1"}) + }) + + it("lets an executed tool result supersede a later approval response", () => { + const part = firstPart([ + ...approvalRecords(), + record("record-result", { + type: "tool_result", + id: "tool-1", + output: "done", + }), + record("record-response", { + type: "interaction_response", + id: "approval-1", + kind: "user_approval", + payload: {toolCallId: "tool-1", approved: true}, + }), + ]) + + expect(part.state).toBe("output-available") + expect(part.output).toBe("done") + expect(part.approval).toEqual({id: "approval-1"}) + }) + + it("falls back to the interaction id when the response omits the tool-call id", () => { + const part = firstPart([ + ...approvalRecords(), + record("record-response", { + type: "interaction_response", + id: "approval-1", + kind: "user_approval", + payload: {approved: false}, + }), + ]) + + expect(part.state).toBe("approval-responded") + expect(part.approval).toEqual({id: "approval-1", approved: false}) + }) + + it("reopens deferred call b when its turn-2 approval request arrives", () => { + const messages = transcriptToMessages([ + record("record-user", {type: "message", text: "run both writes"}, "user"), + record("record-call-a", { + type: "tool_call", + id: "tool-a", + name: "bash", + input: {command: "write a"}, + }), + record("record-call-b", { + type: "tool_call", + id: "tool-b", + name: "bash", + input: {command: "write b"}, + }), + record("record-request-a", { + type: "interaction_request", + id: "approval-a", + kind: "user_approval", + payload: {toolCallId: "tool-a"}, + }), + record("record-result-b-deferred", { + type: "tool_result", + id: "tool-b", + output: "DEFERRED_NOT_EXECUTED: paused for another approval; retry the same call if still required.", + isError: true, + }), + record("record-done-turn-1", {type: "done"}), + record("record-user-turn-2", {type: "message", text: "run both writes"}, "user"), + record("record-response-a", { + type: "interaction_response", + id: "approval-a", + kind: "user_approval", + payload: {toolCallId: "tool-a", approved: true}, + }), + record("record-request-b", { + type: "interaction_request", + id: "approval-b", + kind: "user_approval", + payload: { + toolCallId: "tool-b", + toolCall: { + toolCallId: "tool-b", + name: "bash", + rawInput: {command: "write b"}, + }, + }, + }), + record("record-result-a", { + type: "tool_result", + id: "tool-a", + output: APPROVED_EXECUTION_RESULT_UNKNOWN, + isError: true, + }), + record("record-done-turn-2", {type: "done"}), + ]) + + expect(messages).not.toBeNull() + expect(messages?.[0]).toMatchObject({ + role: "user", + parts: [{type: "text", text: "run both writes"}], + }) + const assistantParts = messages + ?.filter((message) => message.role === "assistant") + .flatMap((message) => message.parts) as unknown as Record[] + const callA = assistantParts.find((part) => part.toolCallId === "tool-a") + const callB = assistantParts.find((part) => part.toolCallId === "tool-b") + + expect(callA).toMatchObject({ + state: "output-error", + errorText: APPROVED_EXECUTION_RESULT_UNKNOWN, + approval: {id: "approval-a", approved: true}, + }) + expect(callB).toEqual({ + type: "tool-bash", + toolCallId: "tool-b", + state: "approval-requested", + input: {command: "write b"}, + approval: {id: "approval-b"}, + }) + expect(assistantParts.filter((part) => part.state === "approval-requested")).toEqual([ + callB, + ]) + }) + + it("keeps a real tool error closed when a late approval request arrives", () => { + const part = firstPart([ + record("record-call-b", { + type: "tool_call", + id: "tool-b", + name: "bash", + input: {command: "write b"}, + }), + record("record-result-b", { + type: "tool_result", + id: "tool-b", + output: "permission denied", + isError: true, + }), + record("record-done-turn-1", {type: "done"}), + record("record-request-b", { + type: "interaction_request", + id: "approval-b", + kind: "user_approval", + payload: {toolCallId: "tool-b"}, + }), + ]) + + expect(part).toEqual({ + type: "tool-bash", + toolCallId: "tool-b", + state: "output-error", + input: {command: "write b"}, + errorText: "permission denied", + }) + }) +}) diff --git a/web/oss/src/components/AgentChatSlice/assets/transcriptToMessages.ts b/web/oss/src/components/AgentChatSlice/assets/transcriptToMessages.ts index b7c3aec300..6483f57397 100644 --- a/web/oss/src/components/AgentChatSlice/assets/transcriptToMessages.ts +++ b/web/oss/src/components/AgentChatSlice/assets/transcriptToMessages.ts @@ -18,6 +18,15 @@ import type {UIMessage} from "ai" type Part = Record +// Mirrors services/runner/src/tracing/otel.ts; park sentinels report skipped or unobserved work, not final results. +export const DEFERRED_NOT_EXECUTED_PREFIX = "DEFERRED_NOT_EXECUTED" +export const APPROVED_EXECUTION_RESULT_UNKNOWN = + "APPROVED_EXECUTION_RESULT_UNKNOWN: the approved call started but its result was not observed before the pause ended the turn; do not assume it failed and do not retry a side-effecting call." +export const APPROVED_EXECUTION_RESULT_UNKNOWN_PREFIX = APPROVED_EXECUTION_RESULT_UNKNOWN.slice( + 0, + APPROVED_EXECUTION_RESULT_UNKNOWN.indexOf(":"), +) + interface DraftMessage { id: string role: "user" | "assistant" @@ -25,14 +34,17 @@ interface DraftMessage { /** Open streamed text/reasoning parts keyed by event id, for delta accumulation. */ text: Map reasoning: Map - /** Tool parts keyed by toolCallId so results/approvals attach to the right call. */ - tools: Map /** The turn's observability trace id, if the durable record carries one (see below). */ traceId?: string /** Token/cost totals from the turn's persisted `usage` event, in the raw stream shape. */ usage?: {input?: number; output?: number; total?: number; cost?: number} } +interface TranscriptIndex { + tools: Map + approvals: Map +} + const roleOf = (sender?: string | null): "user" | "assistant" => sender === "user" ? "user" : "assistant" @@ -74,13 +86,24 @@ const newDraft = (id: string, role: "user" | "assistant"): DraftMessage => ({ parts: [], text: new Map(), reasoning: new Map(), - tools: new Map(), }) const toolPartType = (name?: string | null): string => (name ? `tool-${name}` : "dynamic-tool") +const isRunnerSentinelError = (part: Part): boolean => { + const errorText = typeof part.errorText === "string" ? part.errorText : "" + return ( + errorText.startsWith(DEFERRED_NOT_EXECUTED_PREFIX) || + errorText === APPROVED_EXECUTION_RESULT_UNKNOWN + ) +} + /** Apply one transcript event's payload onto the current assistant/user draft message. */ -function applyEvent(draft: DraftMessage, payload: Record): void { +function applyEvent( + draft: DraftMessage, + payload: Record, + index: TranscriptIndex, +): void { const type = payload.type as string | undefined const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v)) @@ -124,11 +147,11 @@ function applyEvent(draft: DraftMessage, payload: Record): void input: payload.input, } draft.parts.push(part) - draft.tools.set(toolCallId, part) + index.tools.set(toolCallId, part) return } case "tool_result": { - const part = draft.tools.get(str(payload.id)) + const part = index.tools.get(str(payload.id)) if (!part) return if (payload.denied) { part.state = "output-denied" @@ -151,7 +174,7 @@ function applyEvent(draft: DraftMessage, payload: Record): void const toolCallId = str( reqPayload.toolCallId ?? toolCall.id ?? toolCall.toolCallId ?? payload.id, ) - let part = draft.tools.get(toolCallId) + let part = index.tools.get(toolCallId) if (!part) { // The runner parked without first surfacing the tool call — synthesize one. part = { @@ -165,15 +188,39 @@ function applyEvent(draft: DraftMessage, payload: Record): void input: toolCall.rawInput ?? toolCall.input, } draft.parts.push(part) - draft.tools.set(toolCallId, part) + index.tools.set(toolCallId, part) } - // Only park if still unsettled — a later `tool_result` overwrites this. - if (part.state === "input-available") { + index.approvals.set(str(payload.id), part) + const canRequestApproval = + part.state === "input-available" || + (part.state === "output-error" && isRunnerSentinelError(part)) + if (canRequestApproval) { + delete part.errorText + delete part.output part.state = "approval-requested" part.approval = {id: str(payload.id)} } return } + case "interaction_response": { + if (payload.kind !== "user_approval") return + const responsePayload = (payload.payload ?? {}) as Record + const responseId = str(payload.id) + const toolCallId = str(responsePayload.toolCallId) + const part = + (toolCallId ? index.tools.get(toolCallId) : undefined) ?? + index.approvals.get(responseId) + if ( + !part || + part.state !== "approval-requested" || + typeof responsePayload.approved !== "boolean" + ) { + return + } + part.state = "approval-responded" + part.approval = {id: responseId, approved: responsePayload.approved} + return + } case "file": { draft.parts.push({ type: "file", @@ -218,6 +265,8 @@ function applyEvent(draft: DraftMessage, payload: Record): void export function transcriptToMessages(records: SessionRecord[]): UIMessage[] | null { const drafts: DraftMessage[] = [] let current: DraftMessage | null = null + // Paused resumes close the draft, but later answers and results still target its tool part. + const index: TranscriptIndex = {tools: new Map(), approvals: new Map()} for (const row of records) { const payload = row.payload @@ -240,7 +289,7 @@ export function transcriptToMessages(records: SessionRecord[]): UIMessage[] | nu drafts.push(current) } if (traceId && !current.traceId) current.traceId = traceId - applyEvent(current, p) + applyEvent(current, p, index) } const messages = drafts diff --git a/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx b/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx index ad4a9865c3..2f2ba36e0c 100644 --- a/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx +++ b/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx @@ -3,9 +3,11 @@ import {memo, useEffect, useMemo, useRef, useState} from "react" import {HeightCollapse} from "@agenta/ui" import {ArrowSquareOut, CaretRight, ShieldCheck} from "@phosphor-icons/react" import type {ToolUIPart, UIMessage} from "ai" -import {Button, Typography} from "antd" +import {Button, Switch, Typography} from "antd" import {useAtomValue} from "jotai" +import {useAlwaysAllowTool} from "@/oss/hooks/useAlwaysAllowTool" + import {partToolName, resolveToolDisplay} from "../assets/toolDisplay" import {chatPanelMaximizedAtom} from "../state/panelLayout" @@ -125,21 +127,47 @@ const ApprovalDock = ({ className, }: ApprovalDockProps) => { const open = approvals.length > 0 - // Latch the last non-empty set so the card stays visible while the dock animates closed — a - // leave transition needs its content to persist through the height collapse. + // A resolve can answer SEVERAL gates at once — "Approve all", or "Approve" with the always-allow + // toggle on (which also clears that tool's other pending gates, since the user asked not to be + // prompted for it again). Each response settles asynchronously (the SDK's serial job queue), so + // the pending set shrinks across renders; without a latch the dock would step through the batch + // ("1 of 3 → 1 of 2"). `resolvingIds` holds the gates we fired responses for; while any is still + // pending we FREEZE the shown set so the card holds steady and the dock closes in one step (or, + // if only some gates were covered, then steps to the uncovered remainder). + const [resolvingIds, setResolvingIds] = useState(null) + const [resolveSource, setResolveSource] = useState<"all" | "one" | null>(null) + const resolving = + resolvingIds !== null && approvals.some((a) => resolvingIds.includes(a.approvalId)) + // Latch the last non-empty set so the card stays visible while the dock animates closed (a leave + // transition needs its content through the height collapse) AND so a multi-gate resolve doesn't + // step through the batch. const shownRef = useRef(approvals) - if (open) shownRef.current = approvals + if (open && !resolving) shownRef.current = approvals const shown = shownRef.current const current = shown[0] const count = shown.length const [responding, setResponding] = useState(false) + // Armed "always allow this tool" intent for the current gate — applied only when the user + // clicks Approve, never on its own (the switch must not progress the flow). + const [alwaysAllowArmed, setAlwaysAllowArmed] = useState(false) - // The current gate changed (we answered one, the next slid in) — re-enable. + // The current gate changed (we answered one, the next slid in) — re-enable and disarm. Held + // during a resolve (current is frozen), so it fires only on a real step or a new batch. useEffect(() => { setResponding(false) + setResolveSource(null) + setAlwaysAllowArmed(false) }, [current?.approvalId]) + // Once every gate we fired has settled (left the pending set), drop the latch — the dock then + // closes if nothing remains, or re-latches onto the uncovered gates (a mixed-tool batch). + useEffect(() => { + if (resolvingIds !== null && !approvals.some((a) => resolvingIds.includes(a.approvalId))) { + setResolvingIds(null) + } + }, [approvals, resolvingIds]) + // Friendly bodies are Chat-mode (maximized) sugar and need a revision to diff against; // Build and the entityId-less host keep the exact-payload card. const chatMode = useAtomValue(chatPanelMaximizedAtom) @@ -152,28 +180,54 @@ const ApprovalDock = ({ // A source badge we can state factually from the tool name — not a guessed risk level. const source = friendly?.kind === "mcp" ? "MCP tool" : null + // "Always allow this tool": writes a config permission so the runner stops gating this tool + // (per-tool `permission` for gateway/custom-function tools; `harness.permissions.allow` for + // harness builtins like bash). Platform ops (commit_revision, schedules) and MCP are not + // eligible, so they always stay gated. The write happens on APPROVE (see `respond`), never when + // the switch is toggled — the switch only arms the intent. buildAgentRequest re-reads the draft + // on resume, so the grant takes effect for the current run and every future one. + const {infoFor, grant} = useAlwaysAllowTool(entityId) + const grantInfo = current ? infoFor(current.toolName) : null + const canAlwaysAllow = Boolean(grantInfo?.eligible && !grantInfo.alreadyAllowed) + const respond = (approved: boolean) => { if (responding || !current) return setResponding(true) + setResolveSource("one") + // Apply the armed grant only on approve — never on deny, and never from the switch alone. + if (approved && alwaysAllowArmed && canAlwaysAllow) { + grant(current.toolName) + // "Always allow " also clears this tool's OTHER pending gates in the batch: the + // user said they don't want to be prompted for it again, so its siblings auto-approve + // in one step instead of making them click through 2/3, 3/3. Other tools stay gated and + // are shown next. + const covered = shown.filter((a) => a.toolName === current.toolName) + if (covered.length > 1) { + setResolvingIds(covered.map((a) => a.approvalId)) + covered.forEach((a) => onApprovalResponse({id: a.approvalId, approved: true})) + return + } + } onApprovalResponse({id: current.approvalId, approved}) } const approveAll = () => { if (responding) return setResponding(true) + setResolveSource("all") + if (alwaysAllowArmed && canAlwaysAllow && current) grant(current.toolName) + // Freeze the card so the dock doesn't step through the batch as each response settles — it + // holds "1 of N" and closes once all are answered (see `resolvingIds`). + setResolvingIds(shown.map((a) => a.approvalId)) shown.forEach((a) => onApprovalResponse({id: a.approvalId, approved: true})) } - // Always mounted; enter + leave animate via the grid-rows 0fr↔1fr height collapse (+ opacity), - // the same idiom as the reasoning block and composer attachments. `inert` while closed drops the - // (clipped, latched) card from tab order + a11y so a keyboard user can't reach hidden buttons. + // Always mounted; enter + leave animate via the shared HeightCollapse (CSS height + fade, + // reduced-motion-proof) — the same primitive the queue, connect banner, and config sections use. + // `inert` while closed drops the (clipped, latched) card from tab order + a11y so a keyboard user + // can't reach hidden buttons. return ( -
-
+ +
{current ? ( // The friendly two-pane body needs more air than the one-line payload card.
{count > 1 ? ( - ) : null} @@ -284,17 +342,44 @@ const ApprovalDock = ({
+ + {/* Always-allow: arms a config write-through so this tool stops asking. The + switch only ARMS the intent (it must not progress the flow); the grant is + applied when the user clicks Approve. Shown only for gateway / + custom-function / builtin gates that aren't already allowed. */} + {canAlwaysAllow ? ( +
+ +
+ + Always allow{" "} + + {friendly?.label ?? current.toolName} + {" "} + for this agent + + + Applies when you approve; commit to use it in triggers. + +
+
+ ) : null}
) : null}
- + ) } diff --git a/web/oss/src/components/AgentChatSlice/components/RevealCollapse.tsx b/web/oss/src/components/AgentChatSlice/components/RevealCollapse.tsx index d009d38e7d..cea7128ff1 100644 --- a/web/oss/src/components/AgentChatSlice/components/RevealCollapse.tsx +++ b/web/oss/src/components/AgentChatSlice/components/RevealCollapse.tsx @@ -1,12 +1,14 @@ import {type ReactNode} from "react" +import {HeightCollapse} from "@agenta/ui" + /** - * Appear/disappear collapse for chat-composer chrome (connect-model banner, queued messages, HITL dock). - * Animates height 0↔auto via the grid `0fr`↔`1fr` trick plus opacity — the same idiom the ApprovalDock - * and the config sections use, so everything that enters/leaves the composer region does so consistently. - * Content is clipped by `overflow-hidden` while collapsing; `inert` drops the hidden subtree from tab - * order + a11y. Callers that render nothing when "closed" should latch their last content so it persists - * through the leave (see `ConnectModelBanner`). + * Appear/disappear collapse for chat-composer chrome (connect-model banner, queued messages, HITL + * dock). A thin wrapper over the shared {@link HeightCollapse} with `fade` + `inert`, so everything + * that enters/leaves the composer region collapses the SAME CSS-native, reduced-motion-proof way as + * the config accordion sections and the config-pane notices — one language, no `motion-safe` gate. + * Callers that render nothing when "closed" should latch their last content so it persists through + * the leave (see `ConnectModelBanner`). */ const RevealCollapse = ({ open, @@ -17,14 +19,9 @@ const RevealCollapse = ({ className?: string children: ReactNode }) => ( -
-
{children}
-
+ + {children} + ) export default RevealCollapse diff --git a/web/oss/src/components/AgentChatSlice/components/ToolActivity.tsx b/web/oss/src/components/AgentChatSlice/components/ToolActivity.tsx index 057eb75712..d9675f88bc 100644 --- a/web/oss/src/components/AgentChatSlice/components/ToolActivity.tsx +++ b/web/oss/src/components/AgentChatSlice/components/ToolActivity.tsx @@ -21,6 +21,10 @@ import {DriveFileCard} from "@/oss/components/Drives/DriveFileCard" import {partToolName, resolveToolDisplay, type ToolDisplay} from "../assets/toolDisplay" import {formatToolValue, stripFence} from "../assets/toolFormat" +import { + APPROVED_EXECUTION_RESULT_UNKNOWN_PREFIX, + DEFERRED_NOT_EXECUTED_PREFIX, +} from "../assets/transcriptToMessages" import { expandedValueAtomFamily, setExpandedAtom, @@ -35,9 +39,12 @@ const {Text} = Typography const SETTLED = new Set(["output-available", "output-error", "output-denied"]) const isSettled = (state: string) => SETTLED.has(state) -const DEFERRED_PREFIX = "DEFERRED_NOT_EXECUTED:" const isDeferredError = (errorText: string | undefined): boolean => - !!errorText && errorText.startsWith(DEFERRED_PREFIX) + !!errorText && errorText.startsWith(DEFERRED_NOT_EXECUTED_PREFIX) +const isUnknownResultError = (errorText: string | undefined): boolean => + !!errorText && errorText.startsWith(APPROVED_EXECUTION_RESULT_UNKNOWN_PREFIX) +const isNonFinalRunnerError = (errorText: string | undefined): boolean => + isDeferredError(errorText) || isUnknownResultError(errorText) const isNotHandledOutput = (output: unknown): boolean => !!output && @@ -85,7 +92,9 @@ const rowSummary = (part: ToolUIPart, display?: ToolDisplay): string | null => { } if (part.state === "output-error") { const errorText = (part as {errorText?: string}).errorText - return isDeferredError(errorText) ? "waiting on another approval" : "failed" + if (isDeferredError(errorText)) return "waiting on another approval" + if (isUnknownResultError(errorText)) return "approved, result unknown" + return "failed" } if (part.state === "output-denied") return "denied" return null @@ -100,7 +109,7 @@ const StatusIcon = ({part}: {part: ToolUIPart}) => { return } if (state === "output-error") { - if (isDeferredError((part as {errorText?: string}).errorText)) + if (isNonFinalRunnerError((part as {errorText?: string}).errorText)) return return } @@ -153,7 +162,7 @@ const ToolRow = ({ const input = (part as {input?: unknown}).input const output = (part as {output?: unknown}).output const errorText = (part as {errorText?: string}).errorText - const deferred = state === "output-error" && isDeferredError(errorText) + const nonFinalError = state === "output-error" && isNonFinalRunnerError(errorText) const notHandled = state === "output-available" && isNotHandledOutput(output) // `approval-responded` is resolved (the user answered) — not "running". Its execution shows on // a sibling part, so this must not spin forever (the cold-replay lingering-gate spinner). @@ -169,8 +178,8 @@ const ToolRow = ({ : live && running ? "running…" : detailed - ? deferred - ? "waiting on another approval" + ? nonFinalError + ? rowSummary(part, display) : state === "output-error" ? "failed" : state === "output-denied" @@ -207,7 +216,7 @@ const ToolRow = ({ ) : null} {midText ? ( @@ -245,9 +254,9 @@ const ToolRow = ({ {hasInput ? : null} {hasError ? ( ) : hasOutput ? ( @@ -351,7 +360,7 @@ const ToolActivity = ({ const failed = parts.filter( (p) => (p.state as string) === "output-error" && - !isDeferredError((p as {errorText?: string}).errorText), + !isNonFinalRunnerError((p as {errorText?: string}).errorText), ).length const count = parts.length const single = count === 1 ? resolveToolDisplay(partToolName(parts[0])) : null diff --git a/web/oss/src/components/Playground/Components/AgentCommitNotice.tsx b/web/oss/src/components/Playground/Components/AgentCommitNotice.tsx index 5d2542d3d7..06417e2724 100644 --- a/web/oss/src/components/Playground/Components/AgentCommitNotice.tsx +++ b/web/oss/src/components/Playground/Components/AgentCommitNotice.tsx @@ -1,50 +1,29 @@ -import {useEffect, useLayoutEffect, useRef, useState} from "react" +import {useLayoutEffect, useRef, useState} from "react" import {workflowMolecule} from "@agenta/entities/workflow" import {agentSelfCommitSignalAtom} from "@agenta/shared/state" +import {HeightCollapse} from "@agenta/ui" import {Robot} from "@phosphor-icons/react" import {Button} from "antd" import {useAtom, useAtomValue} from "jotai" /** - * Agent self-commit notice, rendered by MainLayout as the LAST row of the config pane's - * flex column — BELOW the scrolling sections, so it is pinned to the pane's bottom edge - * regardless of content height or scroll position, and can never shift the sections. - * Shown while the shared signal targets the displayed revision; Dismiss clears it (and - * the teal section dots with it). Enters/exits with an opacity + rise transition. + * Agent self-commit notice, rendered by MainLayout as the LAST row of the config pane's flex column + * — BELOW the scrolling sections, so it is pinned to the pane's bottom edge regardless of content + * height or scroll position, and can never shift the sections. Shown while the shared signal targets + * the displayed revision; Dismiss clears it (and the teal section dots with it). Collapses in/out via + * the shared {@link HeightCollapse} (fade + a small Y slide) — the same CSS-native, reduced-motion- + * proof primitive the composer dock and the draft-change notice use. */ const AgentCommitNotice = ({revisionId}: {revisionId: string}) => { const [signal, setSignal] = useAtom(agentSelfCommitSignalAtom) const active = Boolean(signal && revisionId && signal.revisionId === revisionId) - // Latch the last matching signal so the content stays rendered through the exit fade. + // Latch the last matching signal so the content stays rendered through the collapse-out. const lastSignalRef = useRef(signal) if (signal && active) lastSignalRef.current = signal const shownSignal = lastSignalRef.current - // Enter/exit: `render` keeps the node mounted through the exit transition; `shown` - // drives the opacity/translate classes. Double rAF so the hidden state paints first. - const [render, setRender] = useState(active) - const [shown, setShown] = useState(false) - useEffect(() => { - if (active) { - setRender(true) - // Cancel BOTH frames: killing only the outer id lets an already-scheduled inner - // rAF flash the notice back in after the hide branch ran. - let innerRaf = 0 - const raf = requestAnimationFrame(() => { - innerRaf = requestAnimationFrame(() => setShown(true)) - }) - return () => { - cancelAnimationFrame(raf) - cancelAnimationFrame(innerRaf) - } - } - setShown(false) - const t = window.setTimeout(() => setRender(false), 240) - return () => window.clearTimeout(t) - }, [active]) - // Commit message comes from the committed revision entity (the stream part carries only // id/version) — also covers the notice surviving a reload while the signal is set. const revisionData = useAtomValue( @@ -66,63 +45,61 @@ const AgentCommitNotice = ({revisionId}: {revisionId: string}) => { const el = messageRef.current if (!el || expanded) return setOverflowing(el.scrollHeight - el.clientHeight > 1) - }, [commitMessage, expanded, render]) - - if (!render || !shownSignal) return null + }, [commitMessage, expanded, active]) - const rawVersion = shownSignal.version ? String(shownSignal.version) : null + const rawVersion = shownSignal?.version ? String(shownSignal.version) : null const version = rawVersion ? (rawVersion.startsWith("v") ? rawVersion : `v${rawVersion}`) : null return ( -
-
-
- - - -
- - Agent updated this configuration{version ? ` in ${version}` : ""} — - changed sections are marked - - {commitMessage ? ( -
-

- “{commitMessage}” -

- {overflowing || expanded ? ( - + + {shownSignal ? ( +
+
+
+ + + +
+ + Agent updated this configuration + {version ? ` in ${version}` : ""} — changed sections are marked + + {commitMessage ? ( +
+

+ “{commitMessage}” +

+ {overflowing || expanded ? ( + + ) : null} +
) : null}
- ) : null} +
+
- -
-
+ ) : null} + ) } diff --git a/web/oss/src/components/Playground/Components/AlwaysAllowedNotice.tsx b/web/oss/src/components/Playground/Components/AlwaysAllowedNotice.tsx new file mode 100644 index 0000000000..8b24282480 --- /dev/null +++ b/web/oss/src/components/Playground/Components/AlwaysAllowedNotice.tsx @@ -0,0 +1,88 @@ +import {useEffect, useRef} from "react" + +import {draftConfigChangeSignalAtom} from "@agenta/shared/state" +import {HeightCollapse} from "@agenta/ui" +import {ArrowCounterClockwise, ShieldCheck, X} from "@phosphor-icons/react" +import {Button} from "antd" +import {useAtom} from "jotai" + +import {useAlwaysAllowTool} from "@/oss/hooks/useAlwaysAllowTool" + +/** + * "Always allowed" notice — the draft-blue counterpart of {@link AgentCommitNotice}, pinned to the + * bottom of the config pane. Surfaces a per-tool permission the user just granted from the approval + * dock, with Undo, CONTAINED to the config panel (where the write lands) rather than a floating + * toast that pulls focus away. Reads the same draft-change signal that pulses the affected section. + * Undo reverts the write and clears; Dismiss just clears. Collapses in/out via the shared + * {@link HeightCollapse} (fade + a small Y slide) — the same CSS-native, reduced-motion-proof + * primitive the composer dock, queued messages, and accordion sections use. + */ +const AlwaysAllowedNotice = ({revisionId}: {revisionId: string}) => { + const [signal, setSignal] = useAtom(draftConfigChangeSignalAtom) + const active = Boolean( + signal && + revisionId && + signal.revisionId === revisionId && + signal.origin === "approval-dock", + ) + const {revoke} = useAlwaysAllowTool(revisionId) + + // Latch the last matching signal so content stays rendered through the collapse-out. + const lastRef = useRef(signal) + if (signal && active) lastRef.current = signal + const shown = lastRef.current + + // Auto-dismiss 5s after the change (keyed on `at`, so a fresh grant restarts the clock). The + // persistent section draft dot is independent of this signal, so it stays after the banner goes. + useEffect(() => { + if (!active) return + const t = window.setTimeout(() => setSignal(null), 5000) + return () => window.clearTimeout(t) + }, [active, signal?.at, setSignal]) + + const label = shown?.label ?? "this tool" + + return ( + +
+
+
+ + + +
+ + Always allowing {label} + + + Saved to this draft — this tool won't ask again. + +
+
+
+ +
+
+
+
+ ) +} + +export default AlwaysAllowedNotice diff --git a/web/oss/src/components/Playground/Components/MainLayout/index.tsx b/web/oss/src/components/Playground/Components/MainLayout/index.tsx index c18ddf7f5f..f16dbb8830 100644 --- a/web/oss/src/components/Playground/Components/MainLayout/index.tsx +++ b/web/oss/src/components/Playground/Components/MainLayout/index.tsx @@ -28,7 +28,9 @@ import {playgroundEarlyAgentStateAtom} from "@/oss/state/workflow" import {usePlaygroundScrollSync} from "../../hooks/usePlaygroundScrollSync" import AgentCommitNotice from "../AgentCommitNotice" +import AlwaysAllowedNotice from "../AlwaysAllowedNotice" import PlaygroundVariantConfig from "../PlaygroundVariantConfig" +import ProviderKeyNotice from "../ProviderKeyNotice" import type {BaseContainerProps} from "../types" const PlaygroundFocusDrawer = dynamic(() => import("../PlaygroundFocusDrawerAdapter"), { ssr: false, @@ -444,7 +446,11 @@ const PlaygroundMainView = ({ ) : null} {!isComparisonView && isAgentConfig && primaryConfigId ? ( - + <> + + + + ) : null}
diff --git a/web/oss/src/components/Playground/Components/ProviderKeyNotice.tsx b/web/oss/src/components/Playground/Components/ProviderKeyNotice.tsx new file mode 100644 index 0000000000..0217760f14 --- /dev/null +++ b/web/oss/src/components/Playground/Components/ProviderKeyNotice.tsx @@ -0,0 +1,73 @@ +import {useEffect, useRef} from "react" + +import {providerKeyAddedSignalAtom} from "@agenta/shared/state" +import {HeightCollapse} from "@agenta/ui" +import {CheckCircle, X} from "@phosphor-icons/react" +import {Button} from "antd" +import {useAtom} from "jotai" + +/** + * "API key added" notice — the success-green sibling of {@link AgentCommitNotice} / + * {@link AlwaysAllowedNotice}, pinned to the bottom of the config pane. Confirms that a provider key + * the user just connected from the "Connect key" flow has landed and the agent can now run, CONTAINED + * to the config panel (where the change happened) instead of a floating toast that pulls focus away. + * Collapses in/out via the shared {@link HeightCollapse} (fade + a small Y slide) — the same + * CSS-native, reduced-motion-proof primitive the other notices use. Auto-dismisses after a few + * seconds; Dismiss clears it early. + */ +const ProviderKeyNotice = ({revisionId}: {revisionId: string}) => { + const [signal, setSignal] = useAtom(providerKeyAddedSignalAtom) + const active = Boolean(signal && revisionId && signal.revisionId === revisionId) + + // Latch the last matching signal so content stays rendered through the collapse-out. + const lastRef = useRef(signal) + if (signal && active) lastRef.current = signal + const shown = lastRef.current + + // Auto-dismiss 6s after the key lands (keyed on `at`, so a fresh save restarts the clock). + useEffect(() => { + if (!active) return + const t = window.setTimeout(() => setSignal(null), 6000) + return () => window.clearTimeout(t) + }, [active, signal?.at, setSignal]) + + const provider = shown?.provider + + return ( + +
+
+
+ + + +
+ + {provider ? ( + <> + {provider} API key + added + + ) : ( + "API key added" + )} + + + The agent is ready to run. + +
+
+
+
+
+ ) +} + +export default ProviderKeyNotice diff --git a/web/oss/src/components/SessionInspector/api.ts b/web/oss/src/components/SessionInspector/api.ts index 80e992e269..5b53e1304b 100644 --- a/web/oss/src/components/SessionInspector/api.ts +++ b/web/oss/src/components/SessionInspector/api.ts @@ -36,8 +36,11 @@ export async function fetchRecords(sessionId: string, projectId?: string | null) } export async function fetchState(sessionId: string, projectId?: string | null) { - const res = await getSessionsClient().getState({session_id: sessionId}, scope(projectId)) - return res.session_state ?? null + const res = await getSessionsClient().fetchSessionStream( + {session_id: sessionId}, + scope(projectId), + ) + return res.stream ?? null } export async function fetchMounts(sessionId: string, projectId?: string | null) { diff --git a/web/oss/src/hooks/useAlwaysAllowTool.tsx b/web/oss/src/hooks/useAlwaysAllowTool.tsx new file mode 100644 index 0000000000..c645d82c9b --- /dev/null +++ b/web/oss/src/hooks/useAlwaysAllowTool.tsx @@ -0,0 +1,128 @@ +import {useCallback, useMemo, useRef} from "react" + +import {workflowMolecule} from "@agenta/entities/workflow" +import { + findGrantableHarnessTool, + findGrantableTool, + gateRulePattern, + withHarnessToolAllow, + withToolPermission, +} from "@agenta/entity-ui/drill-in" +import {draftConfigChangeSignalAtom} from "@agenta/shared/state" +import {useAtomValue, useSetAtom} from "jotai" + +import {resolveToolDisplay} from "@/oss/components/AgentChatSlice/assets/toolDisplay" + +export interface ToolGrantInfo { + /** The gate maps to a per-tool-config tool (gateway or custom function) whose permission we can set. */ + eligible: boolean + /** The matched tool is already `allow` — the affordance would be a no-op, so hide it. */ + alreadyAllowed: boolean +} + +const INELIGIBLE: ToolGrantInfo = {eligible: false, alreadyAllowed: false} + +/** + * "Always allow this tool" for the approval card. + * + * Config write-through into the draft agent config; `buildAgentRequest` reads the draft, so a grant + * takes effect on the paused run's resume and every future run, and a commit carries it to triggers. + * Two fields, routed by tool class: + * - a gateway / custom-function tool has a `tools[]` entry → per-tool `permission: "allow"` + * (`specPermission`, the highest-precedence gate). Checked FIRST — it outranks any rule, and a + * verbatim rule pattern would otherwise also match its slug. + * - any other harness tool (`bash`, `Terminal`, `Write`, …) has no enforceable per-tool permission + * → an allow-rule in `harness.permissions.allow`, keyed by the gate name VERBATIM: the runner + * matches `pattern === gate.toolName`, and that string is exactly what the card shows (the + * runner stamps it as `resolvedName`, which the egress prefers). Never canonicalize it. + * Platform ops (`commit_revision`, schedules), client tools, and MCP tools return `eligible: false` + * and always stay gated (see `gateRulePattern`). + * + * On grant we raise a single draft-change signal that the config pane consumes two ways: the section + * it landed in pulses for attention, and a contained banner (`AlwaysAllowedNotice`) offers Undo — + * both kept inside the config panel where the change is, rather than a floating toast. `revoke` is + * the exact inverse (`"ask"` for tools, `allowed:false` for harness rules). + */ +export function useAlwaysAllowTool(entityId?: string) { + const config = useAtomValue( + useMemo(() => workflowMolecule.selectors.configuration(entityId ?? ""), [entityId]), + ) + // Latest config for the deferred Undo click, so it never reverts against a stale snapshot. + const configRef = useRef(config) + configRef.current = config + const setConfiguration = useSetAtom(workflowMolecule.actions.updateConfiguration) + // Marks the config section this grant lands in so it can pulse for attention — the user + // acted here in the dock, but the write shows up over in the (maybe off-screen) config pane. + const raiseDraftSignal = useSetAtom(draftConfigChangeSignalAtom) + + const infoFor = useCallback( + (toolName: string): ToolGrantInfo => { + if (!entityId) return INELIGIBLE + // Gateway / custom-function tools carry a per-tool `permission` in `tools[]`. First: + // it outranks a rule, and a verbatim rule pattern would also match its slug. + const tool = findGrantableTool(config, toolName) + if (tool) return {eligible: true, alreadyAllowed: tool.permission === "allow"} + // Any other harness tool (bash, Terminal, Write, …) → `harness.permissions.allow`. + const harnessTool = findGrantableHarnessTool(config, toolName) + if (harnessTool) return {eligible: true, alreadyAllowed: harnessTool.allowed} + // Platform ops (commit_revision, schedules), client tools, MCP → never grantable. + return INELIGIBLE + }, + [entityId, config], + ) + + // Inverse of grant: put the tool back to gated. Reads the LATEST config (ref), since Undo fires + // seconds after the grant and the draft may have moved on. + const revoke = useCallback( + (toolName: string): boolean => { + if (!entityId) return false + const cfg = configRef.current + // Same routing as `grant` — `tools[]` first, then the harness allow-rule. + const tool = findGrantableTool(cfg, toolName) + const pattern = tool ? null : gateRulePattern(toolName) + const next = tool + ? withToolPermission(cfg, toolName, "ask") + : pattern + ? withHarnessToolAllow(cfg, pattern, false) + : null + if (!next) return false + setConfiguration(entityId, next) + return true + }, + [entityId, setConfiguration], + ) + + const grant = useCallback( + (toolName: string): boolean => { + if (!entityId) return false + // Route to the field that matches the gate's tool class (see infoFor). `tools[]` first: + // its per-tool permission outranks a rule, and a verbatim pattern would match its slug. + const tool = findGrantableTool(config, toolName) + const pattern = tool ? null : gateRulePattern(toolName) + const next = tool + ? withToolPermission(config, toolName, "allow") + : pattern + ? withHarnessToolAllow(config, pattern, true) + : null + if (!next) return false + setConfiguration(entityId, next) + // A harness allow-rule writes `harness.permissions`, which surfaces in the Advanced → + // Permissions group (and classifies as an "advanced" draft change); gateway/custom-function + // tools write `tools[]`, surfaced in the Tools section. Pulse the section the change lands in. + raiseDraftSignal({ + revisionId: entityId, + sectionKeys: [tool ? "tools" : "advanced"], + origin: "approval-dock", + summary: `Always allow ${toolName}`, + // Friendly display (matches the approval card) — a gateway tool's raw name is a slug. + label: resolveToolDisplay(toolName).label, + toolName, + at: Date.now(), + }) + return true + }, + [entityId, config, setConfiguration, raiseDraftSignal], + ) + + return {infoFor, grant, revoke} +} diff --git a/web/oss/tailwind.config.ts b/web/oss/tailwind.config.ts index 43687d98fe..170a6ebfee 100644 --- a/web/oss/tailwind.config.ts +++ b/web/oss/tailwind.config.ts @@ -139,6 +139,19 @@ export const createConfig = (content: string[] = []): Config => { fontFamily: { sans: ["var(--font-inter)"], }, + // Config-section title shimmer: sweeps a masked highlight overlay across the + // title (see ConfigAccordionSection). Ungated by motion-safe on purpose, to match + // the motion/react dot ripple it pairs with. + keyframes: { + "config-shimmer": { + "0%": {maskPosition: "180% 0", WebkitMaskPosition: "180% 0"}, + "100%": {maskPosition: "-80% 0", WebkitMaskPosition: "-80% 0"}, + }, + }, + // 2 sweeps, then hold off-screen (forwards) so it ends invisibly — no end flash. + animation: { + "config-shimmer": "config-shimmer 1.8s ease-in-out 2 forwards", + }, colors: { ...antdTailwind, // Theme-aware scales (override the static antd-tailwind values diff --git a/web/packages/agenta-api-client/src/generated/api/resources/mounts/client/Client.ts b/web/packages/agenta-api-client/src/generated/api/resources/mounts/client/Client.ts index 61fb1b2b17..0e891e498d 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/mounts/client/Client.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/mounts/client/Client.ts @@ -116,9 +116,10 @@ export class MountsClient { request: AgentaApi.MountQueryRequest = {}, requestOptions?: MountsClient.RequestOptions, ): Promise> { - const { session_id: sessionId, include_archived: includeArchived, ..._body } = request; + const { session_id: sessionId, agent_id: agentId, include_archived: includeArchived, ..._body } = request; const _queryParams: Record = { session_id: sessionId, + agent_id: agentId, include_archived: includeArchived, }; const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); diff --git a/web/packages/agenta-api-client/src/generated/api/resources/mounts/client/requests/MountQueryRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/mounts/client/requests/MountQueryRequest.ts index 1aa9578739..2c1d841f91 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/mounts/client/requests/MountQueryRequest.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/mounts/client/requests/MountQueryRequest.ts @@ -8,6 +8,7 @@ import type * as AgentaApi from "../../../../index.js"; */ export interface MountQueryRequest { session_id?: string | null; + agent_id?: string | null; include_archived?: boolean; mount?: AgentaApi.MountQuery | null; windowing?: AgentaApi.Windowing | null; diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts index 7cfe7b9ef7..bf269f42e5 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts @@ -39,14 +39,14 @@ export class SessionsClient { public fetchSessionStream( request: AgentaApi.FetchSessionStreamRequest, requestOptions?: SessionsClient.RequestOptions, - ): core.HttpResponsePromise { + ): core.HttpResponsePromise { return core.HttpResponsePromise.fromPromise(this.__fetchSessionStream(request, requestOptions)); } private async __fetchSessionStream( request: AgentaApi.FetchSessionStreamRequest, requestOptions?: SessionsClient.RequestOptions, - ): Promise> { + ): Promise> { const { session_id: sessionId } = request; const _queryParams: Record = { session_id: sessionId, @@ -75,7 +75,7 @@ export class SessionsClient { logging: this._options.logging, }); if (_response.ok) { - return { data: _response.body as AgentaApi.SessionStreamResponseModel, rawResponse: _response.rawResponse }; + return { data: _response.body as AgentaApi.SessionStreamResponse, rawResponse: _response.rawResponse }; } if (_response.error.reason === "status-code") { @@ -98,7 +98,7 @@ export class SessionsClient { } /** - * @param {AgentaApi.SessionStreamCommandRequestModel} request + * @param {AgentaApi.SessionStreamCommandRequest} request * @param {SessionsClient.RequestOptions} requestOptions - Request-specific configuration. * * @throws {@link AgentaApi.UnprocessableEntityError} @@ -109,16 +109,16 @@ export class SessionsClient { * }) */ public setSessionStream( - request: AgentaApi.SessionStreamCommandRequestModel, + request: AgentaApi.SessionStreamCommandRequest, requestOptions?: SessionsClient.RequestOptions, - ): core.HttpResponsePromise { + ): core.HttpResponsePromise { return core.HttpResponsePromise.fromPromise(this.__setSessionStream(request, requestOptions)); } private async __setSessionStream( - request: AgentaApi.SessionStreamCommandRequestModel, + request: AgentaApi.SessionStreamCommandRequest, requestOptions?: SessionsClient.RequestOptions, - ): Promise> { + ): Promise> { const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); const _headers: core.Fetcher.Args["headers"] = mergeHeaders( _authRequest.headers, @@ -147,7 +147,7 @@ export class SessionsClient { }); if (_response.ok) { return { - data: _response.body as AgentaApi.SessionStreamCommandResponseModel, + data: _response.body as AgentaApi.SessionStreamCommandResponse, rawResponse: _response.rawResponse, }; } @@ -244,7 +244,7 @@ export class SessionsClient { } /** - * @param {AgentaApi.SessionStreamQueryRequestModel} request + * @param {AgentaApi.SessionStreamQueryRequest} request * @param {SessionsClient.RequestOptions} requestOptions - Request-specific configuration. * * @throws {@link AgentaApi.UnprocessableEntityError} @@ -253,16 +253,16 @@ export class SessionsClient { * await client.sessions.querySessionStreams() */ public querySessionStreams( - request: AgentaApi.SessionStreamQueryRequestModel = {}, + request: AgentaApi.SessionStreamQueryRequest = {}, requestOptions?: SessionsClient.RequestOptions, - ): core.HttpResponsePromise { + ): core.HttpResponsePromise { return core.HttpResponsePromise.fromPromise(this.__querySessionStreams(request, requestOptions)); } private async __querySessionStreams( - request: AgentaApi.SessionStreamQueryRequestModel = {}, + request: AgentaApi.SessionStreamQueryRequest = {}, requestOptions?: SessionsClient.RequestOptions, - ): Promise> { + ): Promise> { const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); const _headers: core.Fetcher.Args["headers"] = mergeHeaders( _authRequest.headers, @@ -290,10 +290,7 @@ export class SessionsClient { logging: this._options.logging, }); if (_response.ok) { - return { - data: _response.body as AgentaApi.SessionStreamsResponseModel, - rawResponse: _response.rawResponse, - }; + return { data: _response.body as AgentaApi.SessionStreamsResponse, rawResponse: _response.rawResponse }; } if (_response.error.reason === "status-code") { @@ -316,7 +313,7 @@ export class SessionsClient { } /** - * @param {AgentaApi.SessionDetachRequestModel} request + * @param {AgentaApi.SessionDetachRequest} request * @param {SessionsClient.RequestOptions} requestOptions - Request-specific configuration. * * @throws {@link AgentaApi.UnprocessableEntityError} @@ -328,14 +325,14 @@ export class SessionsClient { * }) */ public detachSessionStream( - request: AgentaApi.SessionDetachRequestModel, + request: AgentaApi.SessionDetachRequest, requestOptions?: SessionsClient.RequestOptions, ): core.HttpResponsePromise> { return core.HttpResponsePromise.fromPromise(this.__detachSessionStream(request, requestOptions)); } private async __detachSessionStream( - request: AgentaApi.SessionDetachRequestModel, + request: AgentaApi.SessionDetachRequest, requestOptions?: SessionsClient.RequestOptions, ): Promise>> { const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); @@ -388,7 +385,7 @@ export class SessionsClient { } /** - * @param {AgentaApi.SessionHeartbeatRequestModel} request + * @param {AgentaApi.SessionHeartbeatRequest} request * @param {SessionsClient.RequestOptions} requestOptions - Request-specific configuration. * * @throws {@link AgentaApi.UnprocessableEntityError} @@ -400,16 +397,16 @@ export class SessionsClient { * }) */ public heartbeatSessionStream( - request: AgentaApi.SessionHeartbeatRequestModel, + request: AgentaApi.SessionHeartbeatRequest, requestOptions?: SessionsClient.RequestOptions, - ): core.HttpResponsePromise { + ): core.HttpResponsePromise { return core.HttpResponsePromise.fromPromise(this.__heartbeatSessionStream(request, requestOptions)); } private async __heartbeatSessionStream( - request: AgentaApi.SessionHeartbeatRequestModel, + request: AgentaApi.SessionHeartbeatRequest, requestOptions?: SessionsClient.RequestOptions, - ): Promise> { + ): Promise> { const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); const _headers: core.Fetcher.Args["headers"] = mergeHeaders( _authRequest.headers, @@ -437,10 +434,7 @@ export class SessionsClient { logging: this._options.logging, }); if (_response.ok) { - return { - data: _response.body as AgentaApi.SessionHeartbeatResponseModel, - rawResponse: _response.rawResponse, - }; + return { data: _response.body as AgentaApi.SessionHeartbeatResult, rawResponse: _response.rawResponse }; } if (_response.error.reason === "status-code") { @@ -462,6 +456,82 @@ export class SessionsClient { return handleNonStatusCodeError(_response.error, _response.rawResponse, "POST", "/sessions/streams/heartbeat"); } + /** + * @param {AgentaApi.SetSessionStreamHeaderRequest} request + * @param {SessionsClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.sessions.setSessionStreamHeader({ + * session_id: "session_id", + * body: {} + * }) + */ + public setSessionStreamHeader( + request: AgentaApi.SetSessionStreamHeaderRequest, + requestOptions?: SessionsClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__setSessionStreamHeader(request, requestOptions)); + } + + private async __setSessionStreamHeader( + request: AgentaApi.SetSessionStreamHeaderRequest, + requestOptions?: SessionsClient.RequestOptions, + ): Promise> { + const { session_id: sessionId, body: _body } = request; + const _queryParams: Record = { + session_id: sessionId, + }; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + "sessions/streams/header", + ), + method: "PUT", + headers: _headers, + contentType: "application/json", + queryParameters: { ..._queryParams, ...requestOptions?.queryParams }, + requestType: "json", + body: _body, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as AgentaApi.SessionStreamResponse, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError(_response.error, _response.rawResponse, "PUT", "/sessions/streams/header"); + } + /** * @param {AgentaApi.SessionInteractionCreateRequest} request * @param {SessionsClient.RequestOptions} requestOptions - Request-specific configuration. @@ -1511,27 +1581,308 @@ export class SessionsClient { } /** - * @param {AgentaApi.GetStateRequest} request + * @param {AgentaApi.SessionTurnAppendRequest} request + * @param {SessionsClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.sessions.appendTurn({ + * session_id: "session_id", + * stream_id: "stream_id", + * turn_index: 1, + * harness_kind: "pi_core" + * }) + */ + public appendTurn( + request: AgentaApi.SessionTurnAppendRequest, + requestOptions?: SessionsClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__appendTurn(request, requestOptions)); + } + + private async __appendTurn( + request: AgentaApi.SessionTurnAppendRequest, + requestOptions?: SessionsClient.RequestOptions, + ): Promise> { + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + "sessions/turns/", + ), + method: "POST", + headers: _headers, + contentType: "application/json", + queryParameters: requestOptions?.queryParams, + requestType: "json", + body: request, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as AgentaApi.SessionTurnResponse, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError(_response.error, _response.rawResponse, "POST", "/sessions/turns/"); + } + + /** + * @param {AgentaApi.SessionTurnQueryRequest} request + * @param {SessionsClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.sessions.queryTurns() + */ + public queryTurns( + request: AgentaApi.SessionTurnQueryRequest = {}, + requestOptions?: SessionsClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__queryTurns(request, requestOptions)); + } + + private async __queryTurns( + request: AgentaApi.SessionTurnQueryRequest = {}, + requestOptions?: SessionsClient.RequestOptions, + ): Promise> { + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + "sessions/turns/query", + ), + method: "POST", + headers: _headers, + contentType: "application/json", + queryParameters: requestOptions?.queryParams, + requestType: "json", + body: request, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as AgentaApi.SessionTurnsResponse, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError(_response.error, _response.rawResponse, "POST", "/sessions/turns/query"); + } + + /** + * @param {AgentaApi.FetchTurnRequest} request + * @param {SessionsClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.sessions.fetchTurn({ + * turn_id: "turn_id" + * }) + */ + public fetchTurn( + request: AgentaApi.FetchTurnRequest, + requestOptions?: SessionsClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__fetchTurn(request, requestOptions)); + } + + private async __fetchTurn( + request: AgentaApi.FetchTurnRequest, + requestOptions?: SessionsClient.RequestOptions, + ): Promise> { + const { turn_id: turnId } = request; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `sessions/turns/${core.url.encodePathParam(turnId)}`, + ), + method: "GET", + headers: _headers, + queryParameters: requestOptions?.queryParams, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as AgentaApi.SessionTurnResponse, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError(_response.error, _response.rawResponse, "GET", "/sessions/turns/{turn_id}"); + } + + /** + * @param {AgentaApi.SessionQueryRequest} request * @param {SessionsClient.RequestOptions} requestOptions - Request-specific configuration. * * @throws {@link AgentaApi.UnprocessableEntityError} * * @example - * await client.sessions.getState({ + * await client.sessions.querySessions() + */ + public querySessions( + request: AgentaApi.SessionQueryRequest = {}, + requestOptions?: SessionsClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__querySessions(request, requestOptions)); + } + + private async __querySessions( + request: AgentaApi.SessionQueryRequest = {}, + requestOptions?: SessionsClient.RequestOptions, + ): Promise> { + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + "sessions/query", + ), + method: "POST", + headers: _headers, + contentType: "application/json", + queryParameters: requestOptions?.queryParams, + requestType: "json", + body: request, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as AgentaApi.SessionsResponse, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError(_response.error, _response.rawResponse, "POST", "/sessions/query"); + } + + /** + * @param {AgentaApi.DeleteSessionRequest} request + * @param {SessionsClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.sessions.deleteSession({ * session_id: "session_id" * }) */ - public getState( - request: AgentaApi.GetStateRequest, + public deleteSession( + request: AgentaApi.DeleteSessionRequest, requestOptions?: SessionsClient.RequestOptions, - ): core.HttpResponsePromise { - return core.HttpResponsePromise.fromPromise(this.__getState(request, requestOptions)); + ): core.HttpResponsePromise> { + return core.HttpResponsePromise.fromPromise(this.__deleteSession(request, requestOptions)); } - private async __getState( - request: AgentaApi.GetStateRequest, + private async __deleteSession( + request: AgentaApi.DeleteSessionRequest, requestOptions?: SessionsClient.RequestOptions, - ): Promise> { + ): Promise>> { const { session_id: sessionId } = request; const _queryParams: Record = { session_id: sessionId, @@ -1547,9 +1898,9 @@ export class SessionsClient { (await core.Supplier.get(this._options.baseUrl)) ?? (await core.Supplier.get(this._options.environment)) ?? environments.AgentaApiEnvironment.Default, - "sessions/states/", + "sessions/", ), - method: "GET", + method: "DELETE", headers: _headers, queryParameters: { ..._queryParams, ...requestOptions?.queryParams }, timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, @@ -1560,7 +1911,7 @@ export class SessionsClient { logging: this._options.logging, }); if (_response.ok) { - return { data: _response.body as AgentaApi.SessionStateResponse, rawResponse: _response.rawResponse }; + return { data: _response.body as Record, rawResponse: _response.rawResponse }; } if (_response.error.reason === "status-code") { @@ -1579,33 +1930,32 @@ export class SessionsClient { } } - return handleNonStatusCodeError(_response.error, _response.rawResponse, "GET", "/sessions/states/"); + return handleNonStatusCodeError(_response.error, _response.rawResponse, "DELETE", "/sessions/"); } /** - * @param {AgentaApi.SetStateRequest} request + * @param {AgentaApi.ArchiveSessionRequest} request * @param {SessionsClient.RequestOptions} requestOptions - Request-specific configuration. * * @throws {@link AgentaApi.UnprocessableEntityError} * * @example - * await client.sessions.setState({ - * session_id: "session_id", - * body: {} + * await client.sessions.archiveSession({ + * session_id: "session_id" * }) */ - public setState( - request: AgentaApi.SetStateRequest, + public archiveSession( + request: AgentaApi.ArchiveSessionRequest, requestOptions?: SessionsClient.RequestOptions, - ): core.HttpResponsePromise { - return core.HttpResponsePromise.fromPromise(this.__setState(request, requestOptions)); + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__archiveSession(request, requestOptions)); } - private async __setState( - request: AgentaApi.SetStateRequest, + private async __archiveSession( + request: AgentaApi.ArchiveSessionRequest, requestOptions?: SessionsClient.RequestOptions, - ): Promise> { - const { session_id: sessionId, body: _body } = request; + ): Promise> { + const { session_id: sessionId } = request; const _queryParams: Record = { session_id: sessionId, }; @@ -1620,14 +1970,83 @@ export class SessionsClient { (await core.Supplier.get(this._options.baseUrl)) ?? (await core.Supplier.get(this._options.environment)) ?? environments.AgentaApiEnvironment.Default, - "sessions/states/", + "sessions/archive", ), - method: "PUT", + method: "POST", + headers: _headers, + queryParameters: { ..._queryParams, ...requestOptions?.queryParams }, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as AgentaApi.SessionResponse, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError(_response.error, _response.rawResponse, "POST", "/sessions/archive"); + } + + /** + * @param {AgentaApi.UnarchiveSessionRequest} request + * @param {SessionsClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.sessions.unarchiveSession({ + * session_id: "session_id" + * }) + */ + public unarchiveSession( + request: AgentaApi.UnarchiveSessionRequest, + requestOptions?: SessionsClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__unarchiveSession(request, requestOptions)); + } + + private async __unarchiveSession( + request: AgentaApi.UnarchiveSessionRequest, + requestOptions?: SessionsClient.RequestOptions, + ): Promise> { + const { session_id: sessionId } = request; + const _queryParams: Record = { + session_id: sessionId, + }; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + "sessions/unarchive", + ), + method: "POST", headers: _headers, - contentType: "application/json", queryParameters: { ..._queryParams, ...requestOptions?.queryParams }, - requestType: "json", - body: _body, timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, withCredentials: true, @@ -1636,7 +2055,7 @@ export class SessionsClient { logging: this._options.logging, }); if (_response.ok) { - return { data: _response.body as AgentaApi.SessionStateResponse, rawResponse: _response.rawResponse }; + return { data: _response.body as AgentaApi.SessionResponse, rawResponse: _response.rawResponse }; } if (_response.error.reason === "status-code") { @@ -1655,6 +2074,6 @@ export class SessionsClient { } } - return handleNonStatusCodeError(_response.error, _response.rawResponse, "PUT", "/sessions/states/"); + return handleNonStatusCodeError(_response.error, _response.rawResponse, "POST", "/sessions/unarchive"); } } diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/ArchiveSessionRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/ArchiveSessionRequest.ts new file mode 100644 index 0000000000..4f4ebe776c --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/ArchiveSessionRequest.ts @@ -0,0 +1,11 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * session_id: "session_id" + * } + */ +export interface ArchiveSessionRequest { + session_id: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/GetStateRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/DeleteSessionRequest.ts similarity index 80% rename from web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/GetStateRequest.ts rename to web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/DeleteSessionRequest.ts index 2a6e0c5387..d5f6772b62 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/GetStateRequest.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/DeleteSessionRequest.ts @@ -6,6 +6,6 @@ * session_id: "session_id" * } */ -export interface GetStateRequest { +export interface DeleteSessionRequest { session_id: string; } diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/FetchTurnRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/FetchTurnRequest.ts new file mode 100644 index 0000000000..48053bec8b --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/FetchTurnRequest.ts @@ -0,0 +1,11 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * turn_id: "turn_id" + * } + */ +export interface FetchTurnRequest { + turn_id: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionDetachRequestModel.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionDetachRequest.ts similarity index 83% rename from web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionDetachRequestModel.ts rename to web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionDetachRequest.ts index 493e6b76b3..43431f3f4a 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionDetachRequestModel.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionDetachRequest.ts @@ -7,7 +7,7 @@ * watcher_id: "watcher_id" * } */ -export interface SessionDetachRequestModel { +export interface SessionDetachRequest { session_id: string; watcher_id: string; } diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionHeartbeatRequestModel.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionHeartbeatRequest.ts similarity index 85% rename from web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionHeartbeatRequestModel.ts rename to web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionHeartbeatRequest.ts index f63c331196..2ba9057ba8 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionHeartbeatRequestModel.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionHeartbeatRequest.ts @@ -7,7 +7,7 @@ * replica_id: "replica_id" * } */ -export interface SessionHeartbeatRequestModel { +export interface SessionHeartbeatRequest { session_id: string; replica_id: string; turn_id?: string | null; diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionQueryRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionQueryRequest.ts new file mode 100644 index 0000000000..5967190be9 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionQueryRequest.ts @@ -0,0 +1,12 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../../../../index.js"; + +/** + * @example + * {} + */ +export interface SessionQueryRequest { + references?: AgentaApi.Reference[] | null; + windowing?: AgentaApi.Windowing | null; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionRecordIngestRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionRecordIngestRequest.ts index 8474bc41e8..b6112eee70 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionRecordIngestRequest.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionRecordIngestRequest.ts @@ -14,4 +14,6 @@ export interface SessionRecordIngestRequest { record_type?: string | null; record_source?: string | null; attributes?: Record | null; + turn_id?: string | null; + span_id?: string | null; } diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionStreamCommandRequestModel.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionStreamCommandRequest.ts similarity index 57% rename from web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionStreamCommandRequestModel.ts rename to web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionStreamCommandRequest.ts index 513497529d..5d66eef4a2 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionStreamCommandRequestModel.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionStreamCommandRequest.ts @@ -1,14 +1,16 @@ // This file was auto-generated by Fern from our API Definition. +import type * as AgentaApi from "../../../../index.js"; + /** * @example * { * session_id: "session_id" * } */ -export interface SessionStreamCommandRequestModel { +export interface SessionStreamCommandRequest { session_id: string; - prompt?: string | null; + data?: AgentaApi.WorkflowRequestData | null; force?: boolean; detached?: boolean; } diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionStreamQueryRequestModel.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionStreamQueryRequest.ts similarity index 79% rename from web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionStreamQueryRequestModel.ts rename to web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionStreamQueryRequest.ts index 8597fb46d4..f9dcdaaf27 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionStreamQueryRequestModel.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionStreamQueryRequest.ts @@ -4,7 +4,7 @@ * @example * {} */ -export interface SessionStreamQueryRequestModel { +export interface SessionStreamQueryRequest { session_id?: string | null; is_alive?: boolean | null; is_running?: boolean | null; diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionTurnAppendRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionTurnAppendRequest.ts new file mode 100644 index 0000000000..9f3935de27 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionTurnAppendRequest.ts @@ -0,0 +1,26 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../../../../index.js"; + +/** + * @example + * { + * session_id: "session_id", + * stream_id: "stream_id", + * turn_index: 1, + * harness_kind: "pi_core" + * } + */ +export interface SessionTurnAppendRequest { + session_id: string; + stream_id: string; + turn_index: number; + harness_kind: AgentaApi.HarnessKind; + agent_session_id?: string | null; + sandbox_id?: string | null; + references?: AgentaApi.Reference[] | null; + trace_id?: string | null; + span_id?: string | null; + start_time?: string | null; + end_time?: string | null; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionTurnQueryRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionTurnQueryRequest.ts new file mode 100644 index 0000000000..c49b5ebfc8 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionTurnQueryRequest.ts @@ -0,0 +1,12 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../../../../index.js"; + +/** + * @example + * {} + */ +export interface SessionTurnQueryRequest { + query?: AgentaApi.SessionTurnQuery | null; + windowing?: AgentaApi.Windowing | null; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SetStateRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SetSessionStreamHeaderRequest.ts similarity index 72% rename from web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SetStateRequest.ts rename to web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SetSessionStreamHeaderRequest.ts index a0381b7d4e..c988bb7b2f 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SetStateRequest.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SetSessionStreamHeaderRequest.ts @@ -9,7 +9,7 @@ import type * as AgentaApi from "../../../../index.js"; * body: {} * } */ -export interface SetStateRequest { +export interface SetSessionStreamHeaderRequest { session_id: string; - body: AgentaApi.SessionStateUpsertRequest; + body: AgentaApi.SessionStreamHeaderEdit; } diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/UnarchiveSessionRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/UnarchiveSessionRequest.ts new file mode 100644 index 0000000000..218016d453 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/UnarchiveSessionRequest.ts @@ -0,0 +1,11 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * session_id: "session_id" + * } + */ +export interface UnarchiveSessionRequest { + session_id: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts index 387628d226..865b11028c 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts @@ -1,22 +1,28 @@ +export type { ArchiveSessionRequest } from "./ArchiveSessionRequest.js"; export type { BodyUploadSessionMountFile } from "./BodyUploadSessionMountFile.js"; +export type { DeleteSessionRequest } from "./DeleteSessionRequest.js"; export type { DeleteSessionStreamRequest } from "./DeleteSessionStreamRequest.js"; export type { DownloadSessionMountFileRequest } from "./DownloadSessionMountFileRequest.js"; export type { FetchInteractionRequest } from "./FetchInteractionRequest.js"; export type { FetchSessionMountsRequest } from "./FetchSessionMountsRequest.js"; export type { FetchSessionStreamRequest } from "./FetchSessionStreamRequest.js"; +export type { FetchTurnRequest } from "./FetchTurnRequest.js"; export type { GetRecordEventRequest } from "./GetRecordEventRequest.js"; -export type { GetStateRequest } from "./GetStateRequest.js"; -export type { SessionDetachRequestModel } from "./SessionDetachRequestModel.js"; -export type { SessionHeartbeatRequestModel } from "./SessionHeartbeatRequestModel.js"; +export type { SessionDetachRequest } from "./SessionDetachRequest.js"; +export type { SessionHeartbeatRequest } from "./SessionHeartbeatRequest.js"; export type { SessionInteractionCancelStaleRequest } from "./SessionInteractionCancelStaleRequest.js"; export type { SessionInteractionCreateRequest } from "./SessionInteractionCreateRequest.js"; export type { SessionInteractionQueryRequest } from "./SessionInteractionQueryRequest.js"; export type { SessionInteractionRespondRequest } from "./SessionInteractionRespondRequest.js"; export type { SessionInteractionTransitionRequest } from "./SessionInteractionTransitionRequest.js"; export type { SessionMountQueryRequest } from "./SessionMountQueryRequest.js"; +export type { SessionQueryRequest } from "./SessionQueryRequest.js"; export type { SessionRecordIngestRequest } from "./SessionRecordIngestRequest.js"; export type { SessionRecordQueryRequest } from "./SessionRecordQueryRequest.js"; -export type { SessionStreamCommandRequestModel } from "./SessionStreamCommandRequestModel.js"; -export type { SessionStreamQueryRequestModel } from "./SessionStreamQueryRequestModel.js"; -export type { SetStateRequest } from "./SetStateRequest.js"; +export type { SessionStreamCommandRequest } from "./SessionStreamCommandRequest.js"; +export type { SessionStreamQueryRequest } from "./SessionStreamQueryRequest.js"; +export type { SessionTurnAppendRequest } from "./SessionTurnAppendRequest.js"; +export type { SessionTurnQueryRequest } from "./SessionTurnQueryRequest.js"; +export type { SetSessionStreamHeaderRequest } from "./SetSessionStreamHeaderRequest.js"; export type { SignSessionMountCredentialsRequest } from "./SignSessionMountCredentialsRequest.js"; +export type { UnarchiveSessionRequest } from "./UnarchiveSessionRequest.js"; diff --git a/web/packages/agenta-api-client/src/generated/api/types/CommandMode.ts b/web/packages/agenta-api-client/src/generated/api/types/CommandMode.ts index e64e623d23..6dd9d5a0e3 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/CommandMode.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/CommandMode.ts @@ -1,6 +1,6 @@ // This file was auto-generated by Fern from our API Definition. -/** Derived from the prompt × force matrix. */ +/** Derived from the inputs/data × force matrix. */ export const CommandMode = { Send: "send", Steer: "steer", diff --git a/web/packages/agenta-api-client/src/generated/api/types/HarnessKind.ts b/web/packages/agenta-api-client/src/generated/api/types/HarnessKind.ts new file mode 100644 index 0000000000..d71e859a21 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/HarnessKind.ts @@ -0,0 +1,14 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * The coding agent program a run drives. A backend declares which it supports. + * + * ``pi_core`` is plain Pi; ``pi_agenta`` is Pi with Agenta's forced skills, prompt, and + * policy. Both drive the same ``pi`` ACP agent in the runner; ``claude`` drives Claude Code. + */ +export const HarnessKind = { + PiCore: "pi_core", + Claude: "claude", + PiAgenta: "pi_agenta", +} as const; +export type HarnessKind = (typeof HarnessKind)[keyof typeof HarnessKind]; diff --git a/web/packages/agenta-api-client/src/generated/api/types/HarnessSessionRecord.ts b/web/packages/agenta-api-client/src/generated/api/types/HarnessSessionRecord.ts deleted file mode 100644 index cbffe4da76..0000000000 --- a/web/packages/agenta-api-client/src/generated/api/types/HarnessSessionRecord.ts +++ /dev/null @@ -1,11 +0,0 @@ -// This file was auto-generated by Fern from our API Definition. - -/** - * Per-harness resume state. Value shape of `data.harness_sessions[]`. - */ -export interface HarnessSessionRecord { - /** This harness's own agentSessionId, fed to session/load on resume. */ - agent_session_id?: (string | null) | undefined; - /** Conversation turn number this harness last ran at. Load-eligible only when equal to the conversation's latest_turn_index; otherwise this harness's session file is stale (another harness ran since). */ - turn_index?: (number | null) | undefined; -} diff --git a/web/packages/agenta-api-client/src/generated/api/types/Mount.ts b/web/packages/agenta-api-client/src/generated/api/types/Mount.ts index 54951c0432..782de5ec4f 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/Mount.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/Mount.ts @@ -15,6 +15,7 @@ export interface Mount { id?: (string | null) | undefined; project_id: string; session_id?: (string | null) | undefined; + agent_id?: (string | null) | undefined; data?: AgentaApi.MountData | undefined; flags?: AgentaApi.MountFlags | undefined; tags?: (Record | null) | undefined; diff --git a/web/packages/agenta-api-client/src/generated/api/types/MountCreate.ts b/web/packages/agenta-api-client/src/generated/api/types/MountCreate.ts index 49e362e25d..8480e6d653 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/MountCreate.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/MountCreate.ts @@ -7,6 +7,7 @@ export interface MountCreate { description?: (string | null) | undefined; slug?: (string | null) | undefined; session_id?: (string | null) | undefined; + agent_id?: (string | null) | undefined; flags?: AgentaApi.MountFlags | undefined; tags?: (Record | null) | undefined; meta?: (Record | null) | undefined; diff --git a/web/packages/agenta-api-client/src/generated/api/types/MountQuery.ts b/web/packages/agenta-api-client/src/generated/api/types/MountQuery.ts index 3f09a41603..d6a218f327 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/MountQuery.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/MountQuery.ts @@ -2,5 +2,6 @@ export interface MountQuery { session_id?: (string | null) | undefined; + agent_id?: (string | null) | undefined; include_archived?: boolean | undefined; } diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionHeartbeatResponseModel.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionHeartbeatResponseModel.ts deleted file mode 100644 index 1525ba574c..0000000000 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionHeartbeatResponseModel.ts +++ /dev/null @@ -1,8 +0,0 @@ -// This file was auto-generated by Fern from our API Definition. - -import type * as AgentaApi from "../index.js"; - -export interface SessionHeartbeatResponseModel { - stream?: (AgentaApi.SessionStream | null) | undefined; - replica_id: string; -} diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionHeartbeatResult.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionHeartbeatResult.ts new file mode 100644 index 0000000000..9c79dbda1d --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionHeartbeatResult.ts @@ -0,0 +1,25 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +/** + * A heartbeat's outcome: the reconciled stream plus the session's actual owner replica. + * + * `replica_id` is the replica that currently holds the affinity key after the claim + * (this caller if it won or already held it, another replica otherwise). The runner reads + * it to refuse serving a local sandbox session it does not own. + * + * `stream` is None when a losing replica heartbeats a session that has no row yet: it may + * not create or stamp one, since that row belongs to the owner. + * + * `is_current_turn` (W7.4) is False when this turn_id's alive/running lock was gone or + * reassigned at the moment of this beat — i.e. a cancel/steer/kill interrupted this turn + * since the last heartbeat. The runner's watchdog reads this to abort the in-flight run; + * without it a cancel that raced a heartbeat's nx=True re-acquire would silently re-arm the + * SAME lock under the SAME turn_id and the interruption would never surface. + */ +export interface SessionHeartbeatResult { + stream?: (AgentaApi.SessionStream | null) | undefined; + replica_id: string; + is_current_turn?: boolean | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionInteraction.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionInteraction.ts index cca307742c..a2fdc456c0 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionInteraction.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionInteraction.ts @@ -3,13 +3,13 @@ import type * as AgentaApi from "../index.js"; export interface SessionInteraction { - id?: (string | null) | undefined; - created_at?: (unknown | null) | undefined; - updated_at?: (unknown | null) | undefined; - deleted_at?: (unknown | null) | undefined; + created_at?: (string | null) | undefined; + updated_at?: (string | null) | undefined; + deleted_at?: (string | null) | undefined; created_by_id?: (string | null) | undefined; updated_by_id?: (string | null) | undefined; deleted_by_id?: (string | null) | undefined; + id?: (string | null) | undefined; project_id?: (string | null) | undefined; session_id: string; turn_id?: (string | null) | undefined; diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionMount.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionMount.ts index 16b27386f7..1556a1d8fc 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionMount.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionMount.ts @@ -15,6 +15,7 @@ export interface SessionMount { id?: (string | null) | undefined; project_id: string; session_id: string; + agent_id?: (string | null) | undefined; data?: AgentaApi.MountData | undefined; flags?: AgentaApi.MountFlags | undefined; tags?: (Record | null) | undefined; diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionMountQuery.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionMountQuery.ts index 9c232acfbc..06ad6df822 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionMountQuery.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionMountQuery.ts @@ -2,5 +2,6 @@ export interface SessionMountQuery { session_id: string; + agent_id?: (string | null) | undefined; include_archived?: boolean | undefined; } diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionRecord.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionRecord.ts index 2117e807c8..8f868ae176 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionRecord.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionRecord.ts @@ -1,6 +1,12 @@ // This file was auto-generated by Fern from our API Definition. export interface SessionRecord { + created_at?: (string | null) | undefined; + updated_at?: (string | null) | undefined; + deleted_at?: (string | null) | undefined; + created_by_id?: (string | null) | undefined; + updated_by_id?: (string | null) | undefined; + deleted_by_id?: (string | null) | undefined; record_id: string; session_id: string; project_id: string; @@ -9,5 +15,6 @@ export interface SessionRecord { record_type?: (string | null) | undefined; record_source?: (string | null) | undefined; attributes?: (Record | null) | undefined; - created_at?: (string | null) | undefined; + turn_id?: (string | null) | undefined; + span_id?: (string | null) | undefined; } diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionStateResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionResponse.ts similarity index 58% rename from web/packages/agenta-api-client/src/generated/api/types/SessionStateResponse.ts rename to web/packages/agenta-api-client/src/generated/api/types/SessionResponse.ts index 391e0fa210..f8ed41b31f 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionStateResponse.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionResponse.ts @@ -2,7 +2,7 @@ import type * as AgentaApi from "../index.js"; -export interface SessionStateResponse { +export interface SessionResponse { count?: number | undefined; - session_state?: (AgentaApi.SessionState | null) | undefined; + session?: (AgentaApi.SessionStream | null) | undefined; } diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionState.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionState.ts deleted file mode 100644 index da60c8f41f..0000000000 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionState.ts +++ /dev/null @@ -1,24 +0,0 @@ -// This file was auto-generated by Fern from our API Definition. - -import type * as AgentaApi from "../index.js"; - -export interface SessionState { - created_at?: (string | null) | undefined; - updated_at?: (string | null) | undefined; - deleted_at?: (string | null) | undefined; - created_by_id?: (string | null) | undefined; - updated_by_id?: (string | null) | undefined; - deleted_by_id?: (string | null) | undefined; - /** Own uuid7 pk (state_id). */ - id?: (string | null) | undefined; - project_id?: (string | null) | undefined; - /** Bare session correlator (not an FK). */ - session_id: string; - /** Durable continuity state (resume ids + staleness guard). */ - data?: (AgentaApi.SessionStateData | null) | undefined; - /** Remote sandbox id — the single source of truth resume pointer. */ - sandbox_id?: (string | null) | undefined; - flags?: AgentaApi.SessionStateFlags | undefined; - tags?: (Record | null) | undefined; - meta?: (Record | null) | undefined; -} diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionStateData.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionStateData.ts deleted file mode 100644 index 93616b4aa3..0000000000 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionStateData.ts +++ /dev/null @@ -1,19 +0,0 @@ -// This file was auto-generated by Fern from our API Definition. - -import type * as AgentaApi from "../index.js"; - -/** - * Typed shape of the `data` column: the session's durable continuity state. - * - * Stored in the existing `data` JSON column (no dedicated columns) — every field is - * read and compared in the runner, never queried server-side, so a typed DTO gives the - * contract without a schema change. - */ -export interface SessionStateData { - /** The latest-run harness's agentSessionId; a fast-path mirror of harness_sessions[].agent_session_id. */ - latest_agent_session_id?: (string | null) | undefined; - /** Conversation-level turn counter compared against each harness's turn_index. */ - latest_turn_index?: (number | null) | undefined; - /** Per-harness resume state, keyed by harness id (e.g. 'claude', 'pi'). Durable mirror of the staleness guard. */ - harness_sessions?: (Record | null) | undefined; -} diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionStateFlags.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionStateFlags.ts deleted file mode 100644 index 7635753e07..0000000000 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionStateFlags.ts +++ /dev/null @@ -1,3 +0,0 @@ -// This file was auto-generated by Fern from our API Definition. - -export type SessionStateFlags = {}; diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionStateUpsertRequest.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionStateUpsertRequest.ts deleted file mode 100644 index 945214c2a1..0000000000 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionStateUpsertRequest.ts +++ /dev/null @@ -1,12 +0,0 @@ -// This file was auto-generated by Fern from our API Definition. - -import type * as AgentaApi from "../index.js"; - -export interface SessionStateUpsertRequest { - /** Full replacement of the continuity state (resume ids + staleness guard). */ - data?: (AgentaApi.SessionStateData | null) | undefined; - /** Remote sandbox id to record alongside the continuity state. */ - sandbox_id?: (string | null) | undefined; - /** the writer's conversation turn index; the pointer write is applied only when it is >= the row's data.latest_turn_index. */ - sandbox_turn_index?: (number | null) | undefined; -} diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionStream.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionStream.ts index 0a03b3046e..cc2360682d 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionStream.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionStream.ts @@ -3,13 +3,15 @@ import type * as AgentaApi from "../index.js"; export interface SessionStream { - id: string; created_at?: (string | null) | undefined; updated_at?: (string | null) | undefined; deleted_at?: (string | null) | undefined; created_by_id?: (string | null) | undefined; updated_by_id?: (string | null) | undefined; deleted_by_id?: (string | null) | undefined; + name?: (string | null) | undefined; + description?: (string | null) | undefined; + id?: (string | null) | undefined; project_id: string; session_id: string; flags?: AgentaApi.SessionStreamFlags | undefined; diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionStreamCommandResponseModel.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionStreamCommandResponse.ts similarity index 84% rename from web/packages/agenta-api-client/src/generated/api/types/SessionStreamCommandResponseModel.ts rename to web/packages/agenta-api-client/src/generated/api/types/SessionStreamCommandResponse.ts index 2af3ebaf29..18d1acc503 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionStreamCommandResponseModel.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionStreamCommandResponse.ts @@ -2,7 +2,7 @@ import type * as AgentaApi from "../index.js"; -export interface SessionStreamCommandResponseModel { +export interface SessionStreamCommandResponse { mode: AgentaApi.CommandMode; session_id: string; turn_id?: (string | null) | undefined; diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionStreamHeaderEdit.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionStreamHeaderEdit.ts new file mode 100644 index 0000000000..0fc8591908 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionStreamHeaderEdit.ts @@ -0,0 +1,12 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * The rename edit: a full-PUT of the header fields only. + * + * Distinct from SessionStreamEdit (used by the flag-mirror/heartbeat paths) so the + * liveness-only writes can never carry name/description, and vice versa. + */ +export interface SessionStreamHeaderEdit { + name?: (string | null) | undefined; + description?: (string | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionStreamResponseModel.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionStreamResponse.ts similarity index 79% rename from web/packages/agenta-api-client/src/generated/api/types/SessionStreamResponseModel.ts rename to web/packages/agenta-api-client/src/generated/api/types/SessionStreamResponse.ts index 316e1359dc..c94ce7ba5a 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionStreamResponseModel.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionStreamResponse.ts @@ -2,6 +2,6 @@ import type * as AgentaApi from "../index.js"; -export interface SessionStreamResponseModel { +export interface SessionStreamResponse { stream?: (AgentaApi.SessionStream | null) | undefined; } diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionStreamsResponseModel.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionStreamsResponse.ts similarity index 78% rename from web/packages/agenta-api-client/src/generated/api/types/SessionStreamsResponseModel.ts rename to web/packages/agenta-api-client/src/generated/api/types/SessionStreamsResponse.ts index 4a65cf8d99..aa69417c77 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionStreamsResponseModel.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionStreamsResponse.ts @@ -2,7 +2,7 @@ import type * as AgentaApi from "../index.js"; -export interface SessionStreamsResponseModel { +export interface SessionStreamsResponse { count: number; streams: AgentaApi.SessionStream[]; } diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionTurn.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionTurn.ts new file mode 100644 index 0000000000..3e579fd46f --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionTurn.ts @@ -0,0 +1,25 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface SessionTurn { + created_at?: (string | null) | undefined; + updated_at?: (string | null) | undefined; + deleted_at?: (string | null) | undefined; + created_by_id?: (string | null) | undefined; + updated_by_id?: (string | null) | undefined; + deleted_by_id?: (string | null) | undefined; + id?: (string | null) | undefined; + project_id: string; + session_id: string; + stream_id: string; + turn_index: number; + harness_kind: AgentaApi.HarnessKind; + agent_session_id?: (string | null) | undefined; + sandbox_id?: (string | null) | undefined; + references?: (AgentaApi.Reference[] | null) | undefined; + trace_id?: (string | null) | undefined; + span_id?: (string | null) | undefined; + start_time?: (string | null) | undefined; + end_time?: (string | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionTurnQuery.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionTurnQuery.ts new file mode 100644 index 0000000000..ce85a78b68 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionTurnQuery.ts @@ -0,0 +1,10 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface SessionTurnQuery { + session_id?: (string | null) | undefined; + stream_id?: (string | null) | undefined; + harness_kind?: (AgentaApi.HarnessKind | null) | undefined; + references?: (AgentaApi.Reference[] | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionTurnResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionTurnResponse.ts new file mode 100644 index 0000000000..e4aeb68119 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionTurnResponse.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface SessionTurnResponse { + count?: number | undefined; + turn?: (AgentaApi.SessionTurn | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionTurnsResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionTurnsResponse.ts new file mode 100644 index 0000000000..d76907c197 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionTurnsResponse.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface SessionTurnsResponse { + count?: number | undefined; + turns?: AgentaApi.SessionTurn[] | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionsResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionsResponse.ts new file mode 100644 index 0000000000..f7a494729c --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionsResponse.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface SessionsResponse { + count?: number | undefined; + sessions?: AgentaApi.SessionStream[] | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/SpanInput.ts b/web/packages/agenta-api-client/src/generated/api/types/SpanInput.ts index 59b58e8e62..ea48899f0e 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SpanInput.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/SpanInput.ts @@ -20,6 +20,9 @@ export interface SpanInput { end_time?: (SpanInput.EndTime | null) | undefined; status_code?: (AgentaApi.OTelStatusCode | null) | undefined; status_message?: (string | null) | undefined; + session_id?: (string | null) | undefined; + user_id?: (string | null) | undefined; + agent_id?: (string | null) | undefined; attributes?: (Record | null) | undefined; references?: (AgentaApi.OTelReferenceInput[] | null) | undefined; links?: (AgentaApi.OTelLinkInput[] | null) | undefined; diff --git a/web/packages/agenta-api-client/src/generated/api/types/SpanOutput.ts b/web/packages/agenta-api-client/src/generated/api/types/SpanOutput.ts index fe97744187..946e8d7de2 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SpanOutput.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/SpanOutput.ts @@ -20,6 +20,9 @@ export interface SpanOutput { end_time?: (SpanOutput.EndTime | null) | undefined; status_code?: (AgentaApi.OTelStatusCode | null) | undefined; status_message?: (string | null) | undefined; + session_id?: (string | null) | undefined; + user_id?: (string | null) | undefined; + agent_id?: (string | null) | undefined; attributes?: (Record | null) | undefined; references?: (AgentaApi.OTelReferenceOutput[] | null) | undefined; links?: (AgentaApi.OTelLinkOutput[] | null) | undefined; diff --git a/web/packages/agenta-api-client/src/generated/api/types/SpansNodeInput.ts b/web/packages/agenta-api-client/src/generated/api/types/SpansNodeInput.ts index 46f1cbc944..4333b85943 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SpansNodeInput.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/SpansNodeInput.ts @@ -21,6 +21,9 @@ export interface SpansNodeInput { end_time?: (SpansNodeInput.EndTime | null) | undefined; status_code?: (AgentaApi.OTelStatusCode | null) | undefined; status_message?: (string | null) | undefined; + session_id?: (string | null) | undefined; + user_id?: (string | null) | undefined; + agent_id?: (string | null) | undefined; attributes?: (Record | null) | undefined; references?: (AgentaApi.OTelReferenceInput[] | null) | undefined; links?: (AgentaApi.OTelLinkInput[] | null) | undefined; diff --git a/web/packages/agenta-api-client/src/generated/api/types/SpansNodeOutput.ts b/web/packages/agenta-api-client/src/generated/api/types/SpansNodeOutput.ts index 9d98390ae4..c983aaefa0 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SpansNodeOutput.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/SpansNodeOutput.ts @@ -21,6 +21,9 @@ export interface SpansNodeOutput { end_time?: (SpansNodeOutput.EndTime | null) | undefined; status_code?: (AgentaApi.OTelStatusCode | null) | undefined; status_message?: (string | null) | undefined; + session_id?: (string | null) | undefined; + user_id?: (string | null) | undefined; + agent_id?: (string | null) | undefined; attributes?: (Record | null) | undefined; references?: (AgentaApi.OTelReferenceOutput[] | null) | undefined; links?: (AgentaApi.OTelLinkOutput[] | null) | undefined; diff --git a/web/packages/agenta-api-client/src/generated/api/types/WorkflowRequestData.ts b/web/packages/agenta-api-client/src/generated/api/types/WorkflowRequestData.ts new file mode 100644 index 0000000000..eb3ed4e686 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/WorkflowRequestData.ts @@ -0,0 +1,10 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface WorkflowRequestData { + revision?: (Record | null) | undefined; + parameters?: (Record | null) | undefined; + testcase?: (Record | null) | undefined; + inputs?: (Record | null) | undefined; + trace?: (Record | null) | undefined; + outputs?: (unknown | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/index.ts b/web/packages/agenta-api-client/src/generated/api/types/index.ts index dd5718bfdc..52f31b1f91 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/index.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/index.ts @@ -265,7 +265,7 @@ export * from "./Formatting.js"; export * from "./FullJsonInput.js"; export * from "./FullJsonOutput.js"; export * from "./GatewayToolConfig.js"; -export * from "./HarnessSessionRecord.js"; +export * from "./HarnessKind.js"; export * from "./Header.js"; export * from "./HttpValidationError.js"; export * from "./InviteRequest.js"; @@ -360,7 +360,7 @@ export * from "./SecretDto.js"; export * from "./SecretKind.js"; export * from "./SecretResponseDto.js"; export * from "./Selector.js"; -export * from "./SessionHeartbeatResponseModel.js"; +export * from "./SessionHeartbeatResult.js"; export * from "./SessionIdsResponse.js"; export * from "./SessionInteraction.js"; export * from "./SessionInteractionData.js"; @@ -377,16 +377,18 @@ export * from "./SessionMountsResponse.js"; export * from "./SessionRecord.js"; export * from "./SessionRecordResponse.js"; export * from "./SessionRecordsQueryResponse.js"; -export * from "./SessionState.js"; -export * from "./SessionStateData.js"; -export * from "./SessionStateFlags.js"; -export * from "./SessionStateResponse.js"; -export * from "./SessionStateUpsertRequest.js"; +export * from "./SessionResponse.js"; export * from "./SessionStream.js"; -export * from "./SessionStreamCommandResponseModel.js"; +export * from "./SessionStreamCommandResponse.js"; export * from "./SessionStreamFlags.js"; -export * from "./SessionStreamResponseModel.js"; -export * from "./SessionStreamsResponseModel.js"; +export * from "./SessionStreamHeaderEdit.js"; +export * from "./SessionStreamResponse.js"; +export * from "./SessionStreamsResponse.js"; +export * from "./SessionsResponse.js"; +export * from "./SessionTurn.js"; +export * from "./SessionTurnQuery.js"; +export * from "./SessionTurnResponse.js"; +export * from "./SessionTurnsResponse.js"; export * from "./SimpleApplication.js"; export * from "./SimpleApplicationAdditionalContext.js"; export * from "./SimpleApplicationCreate.js"; @@ -643,6 +645,7 @@ export * from "./WorkflowCatalogTypesResponse.js"; export * from "./WorkflowCreate.js"; export * from "./WorkflowEdit.js"; export * from "./WorkflowFlags.js"; +export * from "./WorkflowRequestData.js"; export * from "./WorkflowResponse.js"; export * from "./WorkflowRevisionCommit.js"; export * from "./WorkflowRevisionCreate.js"; diff --git a/web/packages/agenta-entities/src/session/api/api.ts b/web/packages/agenta-entities/src/session/api/api.ts index da6d67668d..1cfb9da0c1 100644 --- a/web/packages/agenta-entities/src/session/api/api.ts +++ b/web/packages/agenta-entities/src/session/api/api.ts @@ -15,7 +15,6 @@ import { sessionInteractionResponseSchema, sessionInteractionsResponseSchema, sessionRecordsQueryResponseSchema, - sessionStateResponseSchema, sessionStreamCommandResponseSchema, sessionMountsResponseSchema, sessionStreamResponseSchema, @@ -26,7 +25,6 @@ import { type SessionInteractionKind, type SessionInteractionStatusCode, type SessionRecord, - type SessionState, type SessionStream, type SessionStreamCommandResponse, } from "../core/schema" @@ -88,32 +86,6 @@ export interface SessionScopedParams { abortSignal?: AbortSignal } -/** - * Read a session's durable state: the opaque SDK `SessionRecord` + the `sandbox_id` resume - * pointer. Read-only from the FE — the record is owned and written by the runner/SDK, so the - * FE never calls `setState(data)` (it would clobber the runner's record). Returns `null` when - * absent (no run has persisted state yet) or on failure. - */ -export async function getSessionState({ - sessionId, - projectId, - appId, - abortSignal, -}: SessionScopedParams): Promise { - if (!projectId || !sessionId) return null - - const data = await callFern("[getSessionState]", () => - getSessionsClient().getState( - {session_id: sessionId}, - projectScopedRequest(projectId, appId, abortSignal), - ), - ) - if (!data) return null - - const validated = safeParseWithLogging(sessionStateResponseSchema, data, "[getSessionState]") - return validated?.session_state ?? null -} - export interface QueryInteractionsParams extends SessionScopedParams { kind?: SessionInteractionKind status?: SessionInteractionStatusCode @@ -289,7 +261,6 @@ export async function fetchSessionStream({ } export interface CommandSessionStreamParams extends SessionScopedParams { - prompt?: string /** Steal the run lock from whoever holds it. */ force?: boolean /** Fire-and-forget: start the run without holding a connection. */ @@ -313,15 +284,15 @@ export async function commandSessionStream({ projectId, appId, abortSignal, - prompt, force, detached, }: CommandSessionStreamParams): Promise { if (!projectId || !sessionId) return null + // The prompt→request.data (inputs) mapping is defined by the sessions feature owner when the send path gets wired (see PR #5375 body). const data = await callFern("[commandSessionStream]", () => getSessionsClient().setSessionStream( - {session_id: sessionId, prompt, force, detached}, + {session_id: sessionId, force, detached}, projectScopedRequest(projectId, appId, abortSignal), ), ) diff --git a/web/packages/agenta-entities/src/session/core/liveness.ts b/web/packages/agenta-entities/src/session/core/liveness.ts index e74dbd0df9..32871468f0 100644 --- a/web/packages/agenta-entities/src/session/core/liveness.ts +++ b/web/packages/agenta-entities/src/session/core/liveness.ts @@ -75,8 +75,8 @@ export interface SandboxLiveness { * `cold` (disk alive, not warm), or `dead` (disk gone). With no sandbox info the coarse `cold` * stands, so existing callers are unaffected until they start passing sandbox data. * - * FOLLOWUP(sessions,#5197): no caller passes `sandbox` yet — wire a sandbox-liveness signal from - * `getSessionState` through the dot (`sessionDotStatusAtomFamily`) when #5197 exposes it. See + * FOLLOWUP(sessions,#5197): no caller passes `sandbox` yet — wire a sandbox-liveness signal + * through the dot (`sessionDotStatusAtomFamily`) when #5197 exposes it. See * docs/designs/sessions/frontend-integration.md. */ export function refineLifecycleWithSandbox( diff --git a/web/packages/agenta-entities/src/session/core/schema.ts b/web/packages/agenta-entities/src/session/core/schema.ts index 08050ecd67..2283da115f 100644 --- a/web/packages/agenta-entities/src/session/core/schema.ts +++ b/web/packages/agenta-entities/src/session/core/schema.ts @@ -47,24 +47,6 @@ export const sessionRecordsQueryResponseSchema = z.object({ export type SessionRecord = z.infer export type SessionRecordsQueryResponse = z.infer -/** Durable SDK record + sandbox resume pointer. `data` is the opaque SDK `SessionRecord`. */ -export const sessionStateSchema = z.object({ - session_id: z.string(), - data: z.record(z.string(), z.unknown()).nullish(), - sandbox_id: z.string().nullish(), - id: z.string().nullish(), - project_id: z.string().nullish(), - created_at: z.string().nullish(), - updated_at: z.string().nullish(), -}) - -export const sessionStateResponseSchema = z.object({ - count: z.number().nullish(), - session_state: sessionStateSchema.nullish(), -}) - -export type SessionState = z.infer - /** A HITL request raised mid-run. `status` is the lifecycle enum (pending/responded/…). */ export const sessionInteractionSchema = z.object({ id: z.string().nullish(), diff --git a/web/packages/agenta-entities/src/session/index.ts b/web/packages/agenta-entities/src/session/index.ts index 81683d5110..57d4cfb7b5 100644 --- a/web/packages/agenta-entities/src/session/index.ts +++ b/web/packages/agenta-entities/src/session/index.ts @@ -7,7 +7,6 @@ */ export { querySessionRecords, - getSessionState, queryInteractions, fetchInteraction, respondInteraction, @@ -38,12 +37,10 @@ export { export { sessionRecordSchema, sessionRecordsQueryResponseSchema, - sessionStateSchema, sessionInteractionSchema, sessionStreamSchema, type SessionRecord, type SessionRecordsQueryResponse, - type SessionState, type SessionInteraction, type SessionInteractionKind, type SessionInteractionStatusCode, diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx index f9cbad6b6f..3c354709cc 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx @@ -25,20 +25,14 @@ import {memo, useCallback, useEffect, useMemo, useRef, useState} from "react" import {toolActionAvailabilityKey, useToolActionAvailability} from "@agenta/entities/gatewayTool" import type {SchemaProperty} from "@agenta/entities/shared" -import { - agentCreationPrefsAtom, - workflowBuildKitEnabledAtomFamily, - workflowMolecule, -} from "@agenta/entities/workflow" -import { - agentItemIdentity, - classifyAgentChanges, - stableStringify, -} from "@agenta/entities/workflow/commitDiff" -import {agentSelfCommitSignalAtom, openAgentConfigSectionAtom} from "@agenta/shared/state" +import {agentCreationPrefsAtom, workflowBuildKitEnabledAtomFamily} from "@agenta/entities/workflow" +import {agentItemIdentity, stableStringify} from "@agenta/entities/workflow/commitDiff" +import {draftConfigChangeSignalAtom, openAgentConfigSectionAtom} from "@agenta/shared/state" import {stripAgentaMetadataDeep} from "@agenta/shared/utils" +import {HeightCollapse} from "@agenta/ui/components" import { ConfigAccordionSection, + useRecentFlag, type SectionIndicatorTone, } from "@agenta/ui/components/presentational" import {useDrillInUI} from "@agenta/ui/drill-in" @@ -56,6 +50,7 @@ import {Button, Tooltip, Typography} from "antd" import deepEqual from "fast-deep-equal" import {useAtom, useAtomValue, useStore} from "jotai" +import {ChangedPathsProvider} from "../../drawers/shared" import {useOptionalDrillIn} from "../components/MoleculeDrillInContext" import {AddTextLink} from "./AddTextLink" @@ -66,6 +61,12 @@ import {AgentToolSelectorPopover} from "./agentTemplate/AgentToolSelectorPopover import {ConfigItemList} from "./agentTemplate/ConfigItemList" import {ITEM_KINDS, type ItemKind} from "./agentTemplate/itemKinds" import {InstructionsFileRow, type ItemRowStatus} from "./agentTemplate/ItemRow" +import {SectionChangeBody} from "./agentTemplate/SectionChangeBody" +import { + revertPathsTo, + useAgentSectionChanges, + type PanelSectionKey, +} from "./agentTemplate/sectionChanges" import {ToolManagementList} from "./agentTemplate/ToolManagementList" import {useAgentTools} from "./agentTemplate/useAgentTools" import {useConfigItemDrawer} from "./agentTemplate/useConfigItemDrawer" @@ -75,6 +76,7 @@ import {connectionFromConfig, modelIdFromConfig} from "./connectionUtils" import {InstructionsDrawer} from "./InstructionsDrawer" import {JsonObjectEditor} from "./JsonObjectEditor" import {SectionDrawer} from "./SectionDrawer" +import {SectionQuickAction} from "./SectionQuickAction" import {parseGatewayTool, type ParsedGatewayTool, type ToolObj} from "./toolUtils" import {useAgentTriggers} from "./TriggerManagementSection" import {WorkflowReferenceSelector} from "./WorkflowReferenceSelector" @@ -105,17 +107,27 @@ export interface AgentTemplateControlProps { className?: string } -// Draft body for the Model & harness / Advanced section drawers. Isolated into its own component so -// `useModelHarness` (harness-catalog + vault-secrets + build-kit-overlay subscriptions) runs ONLY -// while a section drawer is open — `SectionDrawer` uses `destroyOnClose`, so this mounts on open and -// unmounts on close. Previously a second `useModelHarness` ran in the always-mounted parent, -// subscribing on every agent-config render even with both drawers shut. -const ModelHarnessSectionDrawerBody = ({ +// A section's body, in its own component so `useModelHarness` runs HERE rather than in the parent. +// Two reasons, both load-bearing: +// 1. Context. The hook itself reads the changed-paths / focus filters (to mark rows, open the group +// that changed, and narrow to it). React resolves context at the READER's position, so the hook +// must run BELOW the providers — wrapping its output in them does nothing. A body rendered from +// the parent's `mh` silently ignores both filters. +// 2. Cost. `useModelHarness` carries harness-catalog + vault-secrets + build-kit-overlay +// subscriptions; mounting it per body keeps them scoped to a body that is actually on screen +// (`SectionDrawer` uses `destroyOnClose`; the inline body mounts only while a section has +// uncommitted changes). +const ModelHarnessSectionBody = ({ section, ...params -}: {section: "model-harness" | "advanced"} & Parameters[0]) => { +}: { + section: "model-harness" | "advanced" +} & Parameters[0]) => { const mh = useModelHarness(params) - return <>{section === "advanced" ? mh.advancedDrawerBody : mh.modelHarnessDrawerBody} + if (section === "advanced") { + return <>{mh.advancedDrawerBody} + } + return <>{mh.modelHarnessDrawerBody} } // The four list sections whose open-state is controlled so the accordion can auto-expand when @@ -293,45 +305,51 @@ export const AgentTemplateControl = memo(function AgentTemplateControl({ // NEW revision, diff the configs per section and mark the changed ones. The computed // set is FROZEN on first non-empty result so the user's own subsequent edits don't // drift into the "agent changed this" indication. Dismiss (or the next commit) clears. - const commitSignal = useAtomValue(agentSelfCommitSignalAtom) - const frozenAgentDiffRef = useRef<{signalAt: number; keys: Set} | null>(null) - const agentChangedKeys = useMemo(() => { - if (!commitSignal || !revisionId || commitSignal.revisionId !== revisionId) return null - if (frozenAgentDiffRef.current?.signalAt === commitSignal.at) { - return frozenAgentDiffRef.current.keys - } - try { - const sectionIdToKey: Record = { - model: "model-harness", - instructions: "instructions", - tools: "tools", - mcps: "mcp", - skills: "skills", - params: "advanced", - } - const changed = classifyAgentChanges(commitSignal.prevParameters, {agent: value}) - const keys = new Set( - changed.map((s) => sectionIdToKey[s.id]).filter((k): k is string => Boolean(k)), - ) - if (keys.size === 0) return null - frozenAgentDiffRef.current = {signalAt: commitSignal.at, keys} - return keys - } catch { - return null - } - }, [commitSignal, revisionId, value]) + // Both change sources for this revision, computed ONCE (see `sectionChanges`): `draft` is the + // durable live-vs-committed diff, `agent` is the frozen self-commit diff. Every consumer — + // header indicators, the drawers' property marks — reads this same result. + const sectionChanges = useAgentSectionChanges(revisionId, config) + const agentChangedKeys = sectionChanges.agent?.panelKeys ?? null const agentChangeIndicator = useCallback( (sectionKey: string) => { - if (!agentChangedKeys?.has(sectionKey)) return undefined - const raw = commitSignal?.version ? String(commitSignal.version) : null - const version = raw ? (raw.startsWith("v") ? raw : `v${raw}`) : null + if (!agentChangedKeys?.has(sectionKey as PanelSectionKey)) return undefined + const version = sectionChanges.agentVersion return { tone: "agent" as const, tooltip: `Updated by the agent${version ? ` in ${version}` : ""}`, } }, - [agentChangedKeys, commitSignal?.version], + [agentChangedKeys, sectionChanges.agentVersion], + ) + + // ── Draft change from another pane (e.g. "always allow" in the approval dock) ──────── + // A user-initiated, UNCOMMITTED write to this revision's draft config already shows the + // section's static "draft" dot (headerIndicator). When the write came from another pane, + // make THAT dot pulse briefly for attention — the user acted in the dock, not here — instead + // of adding a competing indicator. Rides on whatever tone the section already carries. + const draftSignal = useAtomValue(draftConfigChangeSignalAtom) + const draftActive = Boolean(draftSignal && revisionId && draftSignal.revisionId === revisionId) + // Covers the two 1.8s pulse sweeps (~3.6s) plus a small buffer, so the ripple/shimmer finish + // on their invisible end frame before unmounting — no end-of-pulse flash. + const draftRecent = useRecentFlag(draftActive ? draftSignal!.at : null, 3900) + const withDraftPulse = useCallback( + ( + sectionKey: string, + base: {tone: SectionIndicatorTone; tooltip?: React.ReactNode} | undefined, + ): {tone: SectionIndicatorTone; tooltip?: React.ReactNode; pulse?: boolean} | undefined => { + if (!draftActive || !draftRecent || !draftSignal!.sectionKeys.includes(sectionKey)) { + return base + } + if (base) return {...base, pulse: true} + return { + tone: "draft", + tooltip: `${draftSignal!.summary ?? "Changed here"} — pending, applies on the next run`, + pulse: true, + } + }, + [draftActive, draftRecent, draftSignal], ) + // Model & harness + Advanced own a lot of coupled, stateful logic (the model/connection state // feeds both sections), so they live in their own hook that returns the summaries + bodies. // @@ -340,7 +358,7 @@ export const AgentTemplateControl = memo(function AgentTemplateControl({ // means a section header NEVER reflects the drawer's unsaved draft // (the reported bug: editing in the open drawer updated the background summary). // - The DRAFT instance (config + build-kit buffer) that drives the OPEN section drawer's body - // now lives inside `ModelHarnessSectionDrawerBody`, mounted only while the drawer is open, so + // now lives inside `ModelHarnessSectionBody`, mounted only while a drawer/inline body is on screen, so // its harness/vault/overlay subscriptions don't run in the background. const mh = useModelHarness({schema, config, onChange, disabled, withTooltip, revisionId}) const draftBuildKitOverride = useMemo( @@ -441,41 +459,81 @@ export const AgentTemplateControl = memo(function AgentTemplateControl({ // ── Draft + validation indicators ───────────────────────────────────────── // Committed (server) template to diff the live config against. Null for a never-saved // draft — then only validation (not draft) indicators show. - const committedConfig = useAtomValue( - useMemo( - () => workflowMolecule.selectors.serverConfiguration(revisionId ?? ""), - [revisionId], - ), - ) as Record | null - const committed = useMemo(() => { - if (!committedConfig) return null - const agent = committedConfig.agent - const template = - agent && typeof agent === "object" && !Array.isArray(agent) ? agent : committedConfig - // Strip agenta metadata so it compares like-for-like against the live value. - return stripAgentaMetadataDeep(template) as Record - }, [committedConfig]) + // The same committed baseline the section diff uses (see `useAgentSectionChanges`) — read once + // there rather than subscribing to `serverConfiguration` a second time and re-unwrapping it. + const committed = sectionChanges.committed - // Header rollup: which sections changed vs the commit. Reuses the commit-diff classifier so - // the grouping matches (model+harness together; advanced = runner/sandbox/params). - const draftSectionKeys = useMemo(() => { - const keys = new Set() - if (!committed) return keys - const map: Record = { - model: "model-harness", - instructions: "instructions", - tools: "tools", - mcps: "mcp", - skills: "skills", - params: "advanced", - } - const local = stripAgentaMetadataDeep(config) as Record - for (const s of classifyAgentChanges(local, committed)) { - const k = map[s.id] - if (k) keys.add(k) - } - return keys - }, [config, committed]) + // Header rollup: which sections changed vs the commit — from the shared diff above, so the + // grouping matches the classifier's (model+harness together; advanced = runner/sandbox/params) + // and the indicators, the drawers' property marks, and any summary can never disagree. + const draftSectionKeys = sectionChanges.draft.panelKeys + + // Revert for the SECTION DRAWERS: restore the given paths to their committed values through the + // drawer's scoped draft (`applyDraftConfig`), never straight to the entity — so an undo behaves + // like any other edit in there and Cancel/Save still mean what they say. + const drawerChangedPaths = useMemo( + () => ({ + ...sectionChanges.draft, + revert: (paths: string[]) => + applyDraftConfig(revertPathsTo(draftConfig ?? config, committed, paths)), + }), + [sectionChanges.draft, applyDraftConfig, committed, draftConfig, config], + ) + + // Revert for the PANEL's own inline bodies: writes the entity draft straight through `onChange`, + // matching how the panel's other inline sections (Tools, Instructions) already behave. Distinct + // from `drawerChangedPaths` above — the drawer has a scoped draft to respect, the panel doesn't. + const panelChangedPaths = useMemo( + () => ({ + ...sectionChanges.draft, + revert: (paths: string[]) => onChange(revertPathsTo(config, committed, paths)), + }), + [sectionChanges.draft, onChange, config, committed], + ) + // The inline body for a drawer-backed section: its own controls, narrowed to what changed — the + // same affordance as the Connect-key field, with a different filter (see SectionChangeBody). + // The body is a COMPONENT rendered inside the providers, never `mh`'s pre-built output: the hook + // reads these filters itself, and context resolves at the reader's position. + const changeBodyFor = useCallback( + (key: PanelSectionKey) => { + // The classifier already assigns each changed path to its own section bucket; read this + // section's paths from there so Advanced never picks up an instructions/tools/mcp/skills edit. + const section = sectionChanges.draft.sectionsByKey.get(key) + const sectionPaths = (section?.scalarChanges ?? []).map((c) => c.key) + if (!sectionPaths.length) return null + return ( + openSectionDrawer(key as "model-harness" | "advanced")} + disabled={disabled} + changes={panelChangedPaths} + > + + + ) + }, + [ + sectionChanges.draft, + openSectionDrawer, + panelChangedPaths, + disabled, + schema, + config, + onChange, + withTooltip, + revisionId, + savedHarnessValue, + ], + ) // Match unchanged values before identity so deleting an earlier item cannot make positional // identities mark every surviving row as edited. Identity then distinguishes new from edited. @@ -646,7 +704,7 @@ export const AgentTemplateControl = memo(function AgentTemplateControl({ if (invalid) return {tone: "invalid", tooltip: invalid} const incomplete = sectionIncompleteTip(key) if (incomplete) return {tone: "incomplete", tooltip: incomplete} - if (draftSectionKeys.has(key)) + if (draftSectionKeys.has(key as PanelSectionKey)) return { tone: "draft", tooltip: DRAFT_TIP[key] ?? "Unsaved changes — not yet committed.", @@ -711,7 +769,43 @@ export const AgentTemplateControl = memo(function AgentTemplateControl({ ) - // Each config section as a descriptor rendered by the accordion. Schema-gated, like before. + // The inline "what changed" bodies for the two drawer-backed sections. Null when the section is + // clean (or the variant is off), which is what keeps it a plain drawer-opening row. + const modelChangeBody = changeBodyFor("model-harness") + const advancedChangeBody = changeBodyFor("advanced") + + /** + * Only a BLOCKING problem opens a section by itself. "Can't run without a provider key" is a + * demand and earns the interruption; "you changed something" is an answer to a question the + * reader may not be asking — the header indicator already says a change is there, and expanding + * every changed section on mount would greet them with an unfolded panel every edit. So the + * change body is what the section shows WHEN OPENED, not a reason to open it. + */ + const needsProviderKeyInline = Boolean(mh.needsProviderKey && mh.providerCredentialsInline) + + // Graceful exit: when the key lands, `needsProviderKeyInline` flips false and the section would + // swap straight to its resolved (drawer-opening) row — the inline pane vanishing in one frame. + // Hold the inline branch mounted for one collapse cycle so the pane can animate closed instead. + // The transition is detected during render (not in an effect) so there's never an un-held frame + // where the pane has already been replaced by the resolved row. + const KEY_PANE_EXIT_MS = 320 + const prevNeedsKeyInlineRef = useRef(needsProviderKeyInline) + const [keyPaneExiting, setKeyPaneExiting] = useState(false) + if (prevNeedsKeyInlineRef.current !== needsProviderKeyInline) { + if (prevNeedsKeyInlineRef.current && !needsProviderKeyInline) setKeyPaneExiting(true) + prevNeedsKeyInlineRef.current = needsProviderKeyInline + } + useEffect(() => { + if (!keyPaneExiting) return + const t = window.setTimeout(() => setKeyPaneExiting(false), KEY_PANE_EXIT_MS) + return () => window.clearTimeout(t) + }, [keyPaneExiting]) + // Show (and keep mounting) the inline pane while it's needed OR animating out. + const showKeyPane = + (needsProviderKeyInline || keyPaneExiting) && Boolean(mh.providerCredentialsInline) + + // Each config section as a descriptor, so it can be rendered in any layout (accordion / + // tabs / cards) without duplicating the content. Schema-gated, like before. const sections = [ mh.hasModelOrHarness && { key: "model-harness", @@ -719,8 +813,37 @@ export const AgentTemplateControl = memo(function AgentTemplateControl({ title: "Model & harness", summary: mh.modelSummary, indicator: headerIndicator("model-harness"), - defaultOpen: true, - onOpen: () => openSectionDrawer("model-harness"), + defaultOpen: needsProviderKeyInline, + // What the section surfaces inline, in precedence order. Dropping `onOpen` is what makes + // a section expand inline instead of routing to the drawer. + // 1. Required info missing (no provider key) — BLOCKING, so it wins: the same key field + // the drawer uses, right here. + // 2. Uncommitted changes — informational: what changed (see `changeBodyFor`). + // 3. Neither — the plain drawer row it has always been. + ...(showKeyPane + ? { + // The body owns its padding (inside the collapse) so it can collapse to zero + // height with no residual — the section body wrapper adds none. + bodyClassName: "", + content: ( + +
+ openSectionDrawer("model-harness")} + disabled={disabled} + > + {mh.providerCredentialsInline} + +
+
+ ), + } + : modelChangeBody + ? {content: modelChangeBody} + : { + onOpen: () => openSectionDrawer("model-harness"), + content: mh.modelHarnessDrawerBody, + }), }, hasInstructions && { key: "instructions", @@ -843,9 +966,17 @@ export const AgentTemplateControl = memo(function AgentTemplateControl({ icon: , title: "Advanced", indicator: headerIndicator("advanced"), + // Never self-opening: nothing in Advanced blocks a run (see `needsProviderKeyInline`). defaultOpen: false, summary: mh.advancedSummary, - onOpen: () => openSectionDrawer("advanced"), + // Uncommitted changes → show them inline (dropping `onOpen` is what expands a section + // instead of routing to the drawer). Nothing changed → the plain drawer row. + ...(advancedChangeBody + ? {content: advancedChangeBody} + : { + onOpen: () => openSectionDrawer("advanced"), + content: mh.advancedDrawerBody, + }), }, ].filter(Boolean) as { key: string @@ -856,8 +987,8 @@ export const AgentTemplateControl = memo(function AgentTemplateControl({ indicator?: {tone: SectionIndicatorTone; tooltip?: string} defaultOpen?: boolean onOpen?: () => void - // Only the inline (non-`onOpen`) sections render a body; drawer-opening sections omit it. - content?: React.ReactNode + bodyClassName?: string + content: React.ReactNode }[] // Keep the item + instruction drawers MOUNTED while they animate closed. Their editing state @@ -889,8 +1020,17 @@ export const AgentTemplateControl = memo(function AgentTemplateControl({ titleBadge={sectionBadge(s.key)} summary={s.summary} extra={s.extra} - indicator={s.indicator ?? agentChangeIndicator(s.key)} + indicator={withDraftPulse( + s.key, + s.indicator ?? agentChangeIndicator(s.key), + )} onOpen={s.onOpen} + bodyClassName={s.bodyClassName} + // The panel body sits in the config section's `px-4` field wrapper + // (PlaygroundConfigSection), so the expanded header's fill bleeds 16px + // each side to the panel edges while its text stays aligned with the + // content below. + headerBand="-mx-4 px-4" noDivider={index === sections.length - 1} {...(controlled ? { @@ -983,17 +1123,24 @@ export const AgentTemplateControl = memo(function AgentTemplateControl({ dirty={sectionDirty} width={mh.modelHarnessDrawerWidth} > - + {/* Marks the exact properties that changed since the commit (and opens the + sub-sections holding them). Must sit ABOVE the body: `useModelHarness` reads the + context at its own position, so a provider rendered *by* the body wouldn't reach + it. Scoped to the draft diff — the drawer's own unsaved edits are its Save gate's + business, not "what changed since the commit". */} + + + - + + + {workflowReference?.enabled && ( diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/ClaudePermissionsControl.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/ClaudePermissionsControl.tsx index 66dcd3b25a..7ce6a2ad8e 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/ClaudePermissionsControl.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/ClaudePermissionsControl.tsx @@ -135,7 +135,15 @@ export const ClaudePermissionsControl = memo(function ClaudePermissionsControl({ return ( <> - + {/* `path` marks the row when it has an uncommitted change (see ChangedPathsProvider). + These are the flattened dot-paths the commit-diff classifier emits for this slice — + `harness.permissions.*` — so an approval card's "Always allow" grant (which appends + to `allow`) marks the Allow-rules row it actually wrote to. */} + value={current.defaultMode ?? undefined} onChange={(v) => write({defaultMode: v ?? null})} @@ -148,6 +156,7 @@ export const ClaudePermissionsControl = memo(function ClaudePermissionsControl({ " toggle: a grant made + * there lands here and can be removed here. It lists what was actually granted rather than a fixed + * menu, because the patterns must match the runner's gate names VERBATIM (`bash`, `Terminal`, …), + * which are harness/runtime-specific and not knowable up front. + */ +import {memo, useCallback, useMemo} from "react" + +import {Tag, Typography} from "antd" + +import {RailField, railInfoLabel} from "../../drawers/shared/RailField" + +export interface PiAutoApproveControlProps { + /** The harness `permissions` object (`harness.permissions`); may be absent. */ + value?: Record | null + /** Called with the next `harness.permissions` object. */ + onChange: (permissions: Record) => void + disabled?: boolean +} + +export const PiAutoApproveControl = memo(function PiAutoApproveControl({ + value, + onChange, + disabled = false, +}: PiAutoApproveControlProps) { + const allow = useMemo(() => { + const list = + value && typeof value === "object" && Array.isArray((value as {allow?: unknown}).allow) + ? ((value as {allow: unknown[]}).allow as unknown[]) + : [] + return list.filter((v): v is string => typeof v === "string") + }, [value]) + + const remove = useCallback( + (pattern: string) => { + const base = value && typeof value === "object" ? value : {} + onChange({...base, allow: allow.filter((entry) => entry !== pattern)}) + }, + [allow, value, onChange], + ) + + return ( + + {allow.length ? ( +
+ {allow.map((pattern) => ( + remove(pattern)} + className="!m-0 !font-mono !text-[11px]" + > + {pattern} + + ))} +
+ ) : ( + + Nothing auto-approved — every gated tool asks each time. + + )} +
+ ) +}) diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SandboxPermissionControl.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SandboxPermissionControl.tsx index 4b0f84286a..9b2544f3b5 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SandboxPermissionControl.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SandboxPermissionControl.tsx @@ -110,7 +110,13 @@ export function SandboxPermissionControl({ return ( <> - + {/* `path` = the dot-path the commit-diff classifier flattens this knob to, so a row with + an uncommitted change marks itself (see ChangedPathsProvider). */} + value={current.networkMode} onChange={(v) => write({networkMode: v})} @@ -121,7 +127,10 @@ export function SandboxPermissionControl({ {current.networkMode === "allowlist" ? ( - + @@ -140,7 +149,11 @@ export function SandboxPermissionControl({ ) : null} - + value={current.filesystem ?? undefined} onChange={(v) => write({filesystem: (v as FilesystemMode | undefined) ?? null})} @@ -152,7 +165,11 @@ export function SandboxPermissionControl({ /> - + value={current.enforcement} onChange={(v) => write({enforcement: v})} diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SectionQuickAction.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SectionQuickAction.tsx new file mode 100644 index 0000000000..e2c507e6c1 --- /dev/null +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SectionQuickAction.tsx @@ -0,0 +1,45 @@ +/** + * SectionQuickAction + * + * The inline body a config section shows when required info is missing — a "resolve it right here" + * affordance instead of the plain row that only opens a drawer. It pairs the minimal control that + * fixes the gap (the SAME input the section's drawer uses, passed as `children`) with a link to the + * full drawer for everything else. First use: the Model & harness section's "Connect key" state + * (the provider API-key field). Kept generic so other sections can adopt the same pattern. + */ +import type {ReactNode} from "react" + +import {ArrowSquareOut} from "@phosphor-icons/react" +import {Button} from "antd" + +export interface SectionQuickActionProps { + /** The minimal control that resolves the missing info (e.g. the provider API-key field). */ + children: ReactNode + /** Opens the section's full configuration drawer. */ + onOpenDetails: () => void + /** Link label; defaults to "Detailed configuration". */ + detailsLabel?: string + disabled?: boolean +} + +export function SectionQuickAction({ + children, + onOpenDetails, + detailsLabel = "Detailed configuration", + disabled, +}: SectionQuickActionProps) { + return ( +
+ {children} + +
+ ) +} diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ProviderCredentialsSection.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ProviderCredentialsSection.tsx index 5f91c8c9ff..53b5a109e0 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ProviderCredentialsSection.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ProviderCredentialsSection.tsx @@ -73,6 +73,25 @@ export interface ProviderCredentialsSectionProps { // presentation disabled?: boolean + /** + * Compact/inline variant: no `ConfigAccordionSection` wrapper (no "Provider credentials" header + * or badge — the host section already owns those) and no provider rail (the selected model + * already fixes the provider). Just the mode toggle over the selected provider's key form or the + * self-managed card. Used when the pane is embedded inline under another section's header. + */ + bare?: boolean + /** Bare variant only: the model picker node, rendered in the header row to the left of the mode + * toggle (`[ model select ⋯ API key | Subscription ]`). */ + modelControl?: ReactNode + /** The displayed revision, threaded to the key form so a save can raise the "API key added" + * config-pane banner scoped to it. */ + revisionId?: string | null + + /** Uncommitted-change indicator for the section header (drawer / change surfaces) — mirrors the + * Harness/Model sections. Ignored in the `bare` variant (it renders no section header). */ + indicator?: {tone: "draft" | "invalid" | "incomplete" | "agent"; tooltip?: ReactNode} + /** Section-scoped revert control, rendered in the header beside the mode toggle. */ + revertControl?: ReactNode } const STANDARD_PREFIX = "std:" @@ -180,6 +199,49 @@ function RailRow({ ) } +/** The same rail entry laid out horizontally — a chip for the top-rail (rotated) layout. `dashed` + * marks an "add" action (opens the Configure drawer) vs a selectable provider. */ +function RailChip({ + active, + dashed, + disabled, + onClick, + tile, + label, + trailing, +}: { + active?: boolean + dashed?: boolean + disabled?: boolean + onClick: () => void + tile: ReactNode + label: string + trailing?: ReactNode +}) { + return ( + + ) +} + export function ProviderCredentialsSection({ mode, onModeChange, @@ -191,6 +253,11 @@ export function ProviderCredentialsSection({ providerNeedsKey, openConfigureProvider, disabled, + bare, + modelControl, + revisionId, + indicator, + revertControl, }: ProviderCredentialsSectionProps) { const standardSecrets = useAtomValue(standardSecretsAtom) const customSecrets = useAtomValue(customSecretsAtom) @@ -276,9 +343,9 @@ export function ProviderCredentialsSection({ const toggleOptions = useMemo( () => [ - modeOptions.includes("agenta") ? {label: "Use API key", value: "agenta"} : null, + modeOptions.includes("agenta") ? {label: "API key", value: "agenta"} : null, modeOptions.includes("self_managed") - ? {label: "Use subscription", value: "self_managed"} + ? {label: "Subscription", value: "self_managed"} : null, ].filter((option): option is {label: string; value: ConnectionMode} => option !== null), [modeOptions], @@ -287,12 +354,287 @@ export function ProviderCredentialsSection({ const showToggle = toggleOptions.length > 1 const guideUrl = selfHostingGuideUrl || DEFAULT_SELF_HOSTING_GUIDE_URL + // Shared segmented styling: a subtle elevated track (not a stark black rectangle on the near-black + // panel) with a theme-inverted selected fill (antd's default thumb resolves near-white in light + // mode, so the active segment would be invisible). Reused by the mode + provider-type toggles. + const segmentedClassName = cn( + "rounded-md border border-solid border-[var(--ag-colorBorder)] !bg-[var(--ag-colorFillTertiary)]", + "[&_.ant-segmented-item-selected]:!bg-[var(--ag-colorText)] [&_.ant-segmented-item-selected]:!text-[var(--ag-colorBgContainer)] [&_.ant-segmented-item-selected]:!shadow-none", + "[&_.ant-segmented-thumb]:!bg-[var(--ag-colorText)] [&_.ant-segmented-thumb]:!shadow-none", + ) + + const toggle = showToggle ? ( + onModeChange(value as ConnectionMode)} + options={toggleOptions} + className={segmentedClassName} + /> + ) : null + + const selfManagedCard = ( +
+
+ +
+
+ + Self-managed + +
    +
  • + + Use a Claude Code or Codex subscription, or any credential the harness + reads from its own environment (env vars, prior logins). + +
  • +
  • + + Agenta stores and injects no key. + +
  • +
  • + + Requires a self-hosted Agenta deployment. + +
  • +
+
+
+ + Read the self-hosting guide → + + {isCloud ? ( + // fallback until colorErrorBg token lands + + Unavailable in the cloud + + ) : null} +
+
+ ) + + // Read-only summary for a configured custom connection (its key edit lives in Settings → Secrets). + const renderCustomSummary = (secret: LlmProvider) => ( +
+
+ + {secret.name} + + + {PROVIDER_LABELS[secret.provider ?? ""] ?? secret.provider} + {" · manage this connection in Settings → Secrets."} + +
+ {secret.models?.length ? ( +
+ + Models + + + {secret.models.join(" · ")} + +
+ ) : null} +
+ ) + + // The selected provider's key form (or a read-only custom-provider summary / empty note). Shared + // by the section's master-detail pane and the bare inline variant. + const providerDetail = selectedStandardSecret ? ( + // Keyed by provider name: ProviderKeyField's internal draft-key useState otherwise survives a + // rail switch, letting provider A's half-typed key get saved under provider B. In the bare + // (compact) view the selected chip already names the provider, so drop the detail header. + + ) : selectedCustomProvider ? ( + renderCustomSummary(selectedCustomProvider) + ) : ( + + No provider configured for this model yet — add one from the list. + + ) + + // Custom-provider "Use custom provider" rows (open the Configure drawer), shared by rail + compact. + const addProviderRows = + openConfigureProvider && visibleAddRows.length + ? visibleAddRows.map((row) => ( + openConfigureProvider(row.kind)} + tile={} + label={row.label} + trailing={ + + } + /> + )) + : null + + const railDetail = ( +
+
+ {visibleStandardSecrets.map((secret) => ( + setManualKey(`${STANDARD_PREFIX}${secret.name}`)} + tile={ + + } + label={secret.title ?? secret.name ?? "Provider"} + trailing={ + secret.key ? ( + + ) : undefined + } + /> + ))} + {visibleCustomSecrets.map((secret) => ( + setManualKey(`${CUSTOM_PREFIX}${secret.name}`)} + tile={ + + } + label={secret.name ?? "Custom provider"} + trailing={ + + } + /> + ))} + {addProviderRows ? ( +
+ + Use custom provider + + {addProviderRows} +
+ ) : null} +
+ +
{providerDetail}
+
+ ) + + // Top-rail (rotated master-detail): the same provider list as `railDetail`, but the rail runs + // horizontally across the top and the detail (the OpenAI heading + key form) fills the width + // below. Suits the narrow inline column, where a side rail leaves the key form cramped. Reuses + // the shared `activeKey`/`setManualKey`/`providerDetail` so selection stays consistent. + const topRail = ( +
+
+ {visibleStandardSecrets.map((secret) => ( + setManualKey(`${STANDARD_PREFIX}${secret.name}`)} + tile={ + + } + label={secret.title ?? secret.name ?? "Provider"} + trailing={ + secret.key ? ( + + ) : undefined + } + /> + ))} + {visibleCustomSecrets.map((secret) => ( + setManualKey(`${CUSTOM_PREFIX}${secret.name}`)} + tile={ + + } + label={secret.name ?? "Custom provider"} + trailing={ + + } + /> + ))} + {openConfigureProvider && visibleAddRows.length + ? visibleAddRows.map((row) => ( + openConfigureProvider(row.kind)} + tile={} + label={row.label} + trailing={ + + } + /> + )) + : null} +
+
{providerDetail}
+
+ ) + + // Compact inline variant: a `[ model select ⋯ API key | Subscription ]` header row over the + // top-rail provider strip (or the self-managed card) — no accordion header/badge; the host + // section owns those. + if (bare) { + return ( +
+ {/* Header: model picker takes the remaining width, mode toggle sits right. */} + {modelControl || toggle ? ( +
+
{modelControl}
+ {toggle} +
+ ) : null} + {mode === "self_managed" ? selfManagedCard : topRail} +
+ ) + } + return ( } title="Provider credentials" status={providerNeedsKey ? "warning" : "complete"} + indicator={indicator} titleBadge={ providerNeedsKey ? ( @@ -304,189 +646,15 @@ export function ProviderCredentialsSection({ summaryCollapsedOnly noDivider extra={ - showToggle ? ( - onModeChange(value as ConnectionMode)} - options={toggleOptions} - className={cn( - "rounded-md border border-solid border-[var(--ag-colorBorder)]", - // antd's default selected-thumb bg/track bg both resolve to - // near-white in light mode, so the "active" segment is invisible — - // force a strong, theme-inverted fill instead (dark-navy-on-white - // in light mode, near-white-on-near-black in dark mode). - "[&_.ant-segmented-item-selected]:!bg-[var(--ag-colorText)] [&_.ant-segmented-item-selected]:!text-[var(--ag-colorBgContainer)] [&_.ant-segmented-item-selected]:!shadow-none", - "[&_.ant-segmented-thumb]:!bg-[var(--ag-colorText)] [&_.ant-segmented-thumb]:!shadow-none", - )} - /> + revertControl || toggle ? ( +
+ {revertControl} + {toggle} +
) : undefined } > - {mode === "self_managed" ? ( -
-
- -
-
- - Self-managed - -
    -
  • - - Use a Claude Code or Codex subscription, or any credential the - harness reads from its own environment (env vars, prior logins). - -
  • -
  • - - Agenta stores and injects no key. - -
  • -
  • - - Requires a self-hosted Agenta deployment. - -
  • -
-
-
- - Read the self-hosting guide → - - {isCloud ? ( - // fallback until colorErrorBg token lands - - Unavailable in the cloud - - ) : null} -
-
- ) : ( -
-
- {visibleStandardSecrets.map((secret) => ( - setManualKey(`${STANDARD_PREFIX}${secret.name}`)} - tile={ - - } - label={secret.title ?? secret.name ?? "Provider"} - trailing={ - secret.key ? ( - - ) : undefined - } - /> - ))} - {visibleCustomSecrets.map((secret) => ( - setManualKey(`${CUSTOM_PREFIX}${secret.name}`)} - tile={ - - } - label={secret.name ?? "OpenAI-compatible endpoint"} - trailing={ - - } - /> - ))} - {openConfigureProvider && visibleAddRows.length ? ( -
- - Add custom provider - - {visibleAddRows.map((row) => ( - openConfigureProvider(row.kind)} - tile={} - label={row.label} - trailing={ - - } - /> - ))} -
- ) : null} -
- -
- {selectedStandardSecret ? ( - // Keyed by provider name: ProviderKeyField's internal draft-key - // useState otherwise survives a rail switch, letting provider A's - // half-typed key get saved under provider B. - - ) : selectedCustomProvider ? ( -
-
- - {selectedCustomProvider.name} - - - {PROVIDER_LABELS[selectedCustomProvider.provider ?? ""] ?? - selectedCustomProvider.provider} - {" · manage this connection in Settings → Secrets."} - -
- {selectedCustomProvider.models?.length ? ( -
- - Models - - - {selectedCustomProvider.models.join(" · ")} - -
- ) : null} -
- ) : ( - - No provider configured for this model yet — add one from the list. - - )} -
-
- )} + {mode === "self_managed" ? selfManagedCard : railDetail}
) } diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ProviderKeyField.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ProviderKeyField.tsx index 250e7079e6..12bc8579e8 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ProviderKeyField.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ProviderKeyField.tsx @@ -1,9 +1,13 @@ -import {useState} from "react" +import {useEffect, useRef, useState} from "react" import {useVaultSecret} from "@agenta/entities/secret" +import {providerKeyAddedSignalAtom} from "@agenta/shared/state" import type {LlmProvider} from "@agenta/shared/types" +import {useAccordionSectionOpen} from "@agenta/ui/components/presentational" import {CheckCircle} from "@phosphor-icons/react" import {App, Button, Input, Typography} from "antd" +import type {InputRef} from "antd" +import {useSetAtom} from "jotai" /** * Right-pane "API key" form for a standard provider — the Provider credentials section's key form @@ -12,19 +16,55 @@ import {App, Button, Input, Typography} from "antd" * exists, and an encryption footnote. Saves to the project vault via `useVaultSecret`, which also * arms `providerKeySetupDoneAtom` — no drawer Save step, so the "Connect key" gate clears reactively. */ -const ProviderKeyField = ({provider, disabled}: {provider: LlmProvider; disabled?: boolean}) => { +const ProviderKeyField = ({ + provider, + disabled, + hideHeader, + revisionId, +}: { + provider: LlmProvider + disabled?: boolean + /** Drop the name + "Standard provider · …" subtitle — used where a selected rail chip already + * names the provider, so repeating it in the detail is noise. */ + hideHeader?: boolean + /** The displayed revision, so a successful save can raise the "API key added" config-pane banner + * scoped to it. */ + revisionId?: string | null +}) => { const {message} = App.useApp() const {handleModifyVaultSecret} = useVaultSecret() + const raiseKeyAddedSignal = useSetAtom(providerKeyAddedSignalAtom) const [key, setKey] = useState("") const [saving, setSaving] = useState(false) + const inputRef = useRef(null) + + // Focus the key input when the enclosing section is open (and each time it re-opens), not just on + // mount: the section body stays mounted while collapsed, so a mount-time `autoFocus` would fire + // while hidden. Only when there's no key yet — an existing key isn't waiting to be typed. + const sectionOpen = useAccordionSectionOpen() + const hasKey = !!provider.key + useEffect(() => { + if (!sectionOpen || hasKey || disabled) return + const t = window.setTimeout(() => inputRef.current?.focus(), 0) + return () => window.clearTimeout(t) + }, [sectionOpen, hasKey, disabled]) const save = async () => { const trimmed = key.trim() if (!trimmed || saving || disabled) return + const isFirstKey = !provider.key setSaving(true) try { await handleModifyVaultSecret({...provider, key: trimmed}) setKey("") + // Only the FIRST key connection unblocks the run — a replace doesn't need the banner. + if (isFirstKey && revisionId) { + raiseKeyAddedSignal({ + revisionId, + provider: provider.title ?? provider.name ?? undefined, + at: Date.now(), + }) + } } catch { // Security-sensitive write — never fail silently; keep the typed value so the user can retry. message.error("Couldn't save the provider key. Please try again.") @@ -33,36 +73,43 @@ const ProviderKeyField = ({provider, disabled}: {provider: LlmProvider; disabled } } - const hasKey = !!provider.key - return ( -
-
- - {provider.title} - - - Standard provider · add your key and we auto-list its models. - - {hasKey ? ( - +
+ {hideHeader ? ( + hasKey ? ( + Key configured · enter a new value to replace it. - ) : null} -
+ ) : null + ) : ( +
+ + {provider.title} + + + Standard provider · add your key and we auto-list its models. + + {hasKey ? ( + + + Key configured · enter a new value to replace it. + + ) : null} +
+ )}
API key *
setKey(e.target.value)} onPressEnter={save} placeholder="sk-…" className="flex-1 font-mono" - autoFocus={!hasKey} disabled={disabled} /> + + ) : undefined + + // FOCUS (see FocusPathsContext): when a surface narrows to the properties that matter — e.g. the + // config panel showing only what changed — a group renders only if it owns one of them, and the + // rows filter themselves. Chrome follows the content: with changes in ONE group there is nothing + // to disambiguate, so it renders FLAT (just the controls, like the Connect-key field); spread + // across several, the group headers earn their keep by saying which change belongs where. + const focus = useFocusPaths() + const sandboxInFocus = useHasFocusUnder("sandbox") + const runnerInFocus = useHasFocusUnder("runner") + // Split like the changed/revert side: harness.kind focuses the Model & harness section, + // harness.permissions the Permissions group — so neither pulls the other into focus. + const harnessKindInFocus = useHasFocusUnder("harness.kind") + const harnessPermsInFocus = useHasFocusUnder("harness.permissions") + const permissionsInFocus = runnerInFocus || harnessPermsInFocus + const focusedGroupCount = (sandboxInFocus ? 1 : 0) + (permissionsInFocus ? 1 : 0) + const flatFocus = focus.active && focusedGroupCount <= 1 + + // Same, for the Model & harness inline body: its three groups own harness.kind / llm.model / + // llm.connection.*, so under a change filter only the group(s) that actually changed render (a + // model swap shows the Model group, and Provider credentials too only if it moved the connection), + // flat when just one survives — instead of unfolding the entire section. + const modelInFocus = useHasFocusUnder("llm.model") + const credentialsInFocus = useHasFocusUnder("llm.connection") + const modelHarnessFocusedCount = + (harnessKindInFocus ? 1 : 0) + (modelInFocus ? 1 : 0) + (credentialsInFocus ? 1 : 0) + const modelHarnessFlatFocus = focus.active && modelHarnessFocusedCount <= 1 + const hasAdvanced = Boolean( sandboxProps.kind || sandboxProps.permissions || @@ -474,6 +570,7 @@ export function useModelHarness({ : "Model" } align="center" + path="llm.model" > {modelControl} @@ -602,18 +699,33 @@ export function useModelHarness({ // Provider credentials section: identical in both the capability-aware and flat layouts below, // rendered once and reused so the two branches don't carry a duplicate prop list. - const providerCredentialsSection = props.llm ? ( + const providerCredentialsProps = props.llm + ? { + mode: connection.mode, + onModeChange: (m: ConnectionMode) => writeModel({mode: m}), + selectedProviderFamily, + selectedConnectionSlug: connection.slug ?? null, + modeOptions, + isCloud, + selfHostingGuideUrl: deployment?.selfHostingGuideUrl, + providerNeedsKey, + openConfigureProvider: openConfigureProviderAdopting, + disabled, + revisionId: revisionId ?? null, + indicator: changedIndicator(credentialsChanged), + revertControl: revertAction(revertCredentials), + } + : null + // The full pane (own header + rail) for the drawer body; the `bare` variant (toggle + key form / + // self-managed, no header, no rail) for the inline "Connect key" quick-action under the section. + const providerCredentialsSection = providerCredentialsProps ? ( + + ) : null + const providerCredentialsInline = providerCredentialsProps ? ( writeModel({mode: m})} - selectedProviderFamily={selectedProviderFamily} - selectedConnectionSlug={connection.slug ?? null} - modeOptions={modeOptions} - isCloud={isCloud} - selfHostingGuideUrl={deployment?.selfHostingGuideUrl} - providerNeedsKey={providerNeedsKey} - openConfigureProvider={openConfigureProviderAdopting} - disabled={disabled} + {...providerCredentialsProps} + bare + modelControl={modelControl} /> ) : null @@ -621,54 +733,83 @@ export function useModelHarness({ // left (each card owns its model-compat state), version history on the right — same two-panel // shape as the Advanced drawer. Without capabilities: the plain harness select, single column. // Shared Model & harness controls — rendered by both the wide drawer body and the tabs-inline body. + // A focused (change/connect-key) surface renders only the groups that own a changed property, and + // drops to FLAT chrome when just one survives; unfocused (drawer/tabs) everything renders as-is. + const modelControlGroup = ( +
+ {modelControl} + {!focus.active && hasInspectModels ? ( + + Filtered to the models this harness can reach. Selecting a model also sets its + provider. + + ) : null} +
+ ) + const modelHarnessControls = capabilities ? ( <> - } - title="Harness" - status={harnessValue ? "complete" : "default"} - summary={selectedHarnessLabel ?? undefined} - summaryCollapsedOnly - > -
- - - The harness is the runtime that executes your agent. It decides which - providers, hosting and connection options you can use. - -
- {harnessSection} -
+ {harnessKindInFocus ? ( + modelHarnessFlatFocus ? ( + harnessSection + ) : ( + } + title="Harness" + status={harnessValue ? "complete" : "default"} + indicator={changedIndicator(harnessKindChanged)} + extra={revertAction(revertHarnessKind)} + summary={selectedHarnessLabel ?? undefined} + summaryCollapsedOnly + > + {focus.active ? null : ( +
+ + + The harness is the runtime that executes your agent. It decides + which providers, hosting and connection options you can use. + +
+ )} + {harnessSection} +
+ ) + ) : null} - } - title="Model" - status={!modelId || !selectedKeepsModel ? "warning" : "complete"} - summary={modelId ?? undefined} - summaryCollapsedOnly - > -
- {modelControl} - {hasInspectModels ? ( - - Filtered to the models this harness can reach. Selecting a model also - sets its provider. - - ) : null} -
-
+ {modelInFocus ? ( + modelHarnessFlatFocus ? ( + // Flat (a change surface narrowed to just the model): a labelled RailField row, so + // the changed control marks itself and its label opens the committed-value + Restore + // popover — the same property-scoped affordance the Advanced rows get. + + {modelControl} + + ) : ( + } + title="Model" + status={!modelId || !selectedKeepsModel ? "warning" : "complete"} + indicator={changedIndicator(modelChanged)} + extra={revertAction(revertModel)} + summary={modelId ?? undefined} + summaryCollapsedOnly + > + {modelControlGroup} + + ) + ) : null} - {providerCredentialsSection} + {credentialsInFocus ? providerCredentialsSection : null} ) : ( <> - {harnessProps.kind && ( - + {harnessKindInFocus && harnessProps.kind && ( + )} - {modelPicker} - {providerCredentialsSection} + {modelInFocus ? modelPicker : null} + {credentialsInFocus ? providerCredentialsSection : null} ) - const modelHarnessDrawerBody = capabilities ? ( -
-
- {modelHarnessControls} + // The two-panel layout (controls + version-history aside) is DRAWER chrome. Under a focus filter + // this same body renders INLINE in the config panel (the "what changed" view), where the version + // history and its fixed-width aside don't belong — drop to a single column of the narrowed controls. + const modelHarnessDrawerBody = + capabilities && !focus.active ? ( +
+
+ {modelHarnessControls} +
+
{versionHistorySkeleton}
-
{versionHistorySkeleton}
-
- ) : ( -
{modelHarnessControls}
- ) + ) : ( +
{modelHarnessControls}
+ ) // Advanced header summary: sandbox only now — mode UI moved to the Provider credentials section. const advancedSummary = sandbox.kind ? `Sandbox: ${String(sandbox.kind)}` : undefined @@ -707,125 +852,206 @@ export function useModelHarness({ // Each group is a `ConfigAccordionSection` (the shared drawer section shell used by the trigger // and tools drawers); inside, configuration reads as the drawer's `[rail | content]` rhythm via // `SectionRail` (mode groups) and `RailField` (labelled control rows). - const advancedControls = ( + /** + * One Advanced group. Under a focus filter it disappears unless it owns a focused property, and + * drops its chrome entirely when it's the only survivor — the body (the real controls) is the + * same either way, so nothing is rendered twice. + */ + const advancedGroup = ( + opts: { + inFocus: boolean + defaultOpen: boolean + indicator: ReturnType + extra: ReactNode + icon: ReactNode + title: string + summary?: string + caption: ReactNode + }, + body: ReactNode, + ) => { + if (!opts.inFocus) return null + // Flat: just the focused control(s) — the group header would only restate the section. + if (flatFocus) return
{body}
+ return ( + + {opts.caption} + {body} + + ) + } + + const executionBody = ( <> - {buildKitSection} - - {hasExecutionGroup ? ( - } - title="Execution environment" - summary={sandbox.kind ? `Sandbox: ${String(sandbox.kind)}` : undefined} - summaryCollapsedOnly - > - - Where the agent's tools and code run, and what that sandbox may touch. - - {sandboxProps.kind ? ( - - setSection("sandbox", {...sandbox, kind: v})} - withTooltip={withTooltip} - disabled={disabled} - /> - - ) : null} - {sandboxProps.permissions && sandbox.kind !== "local" ? ( - <> - {permissionOverrideHint} - {/* Renders its knobs as peer RailField rows (Network egress / Filesystem - / Enforcement) sharing this section's rail — no nested sub-form. */} - | null) ?? null - } - onChange={(v) => - setSection("sandbox", {...sandbox, permissions: v}) - } - disabled={disabled} - /> - - ) : null} - + {sandboxProps.kind ? ( + + setSection("sandbox", {...sandbox, kind: v})} + withTooltip={withTooltip} + disabled={disabled} + /> + + ) : null} + {sandboxProps.permissions && sandbox.kind !== "local" ? ( + <> + {focus.active ? null : permissionOverrideHint} + {/* Renders its knobs as peer RailField rows (Network egress / Filesystem + / Enforcement) sharing this section's rail — no nested sub-form. */} + | null) ?? null} + onChange={(v) => setSection("sandbox", {...sandbox, permissions: v})} + disabled={disabled} + /> + ) : null} + + ) - {hasPermissionsGroup ? ( - } - title="Permissions" - summary={runnerPermissionSummary} - summaryCollapsedOnly - > - - What the agent may do on its own before it must ask. - - {runnerPermissionSchema ? ( - - - value={currentRunnerPermission} - onChange={(v) => - setSection("runner", { - ...runner, - permissions: {...runnerPermissions, default: v}, - }) - } - options={runnerPermissionOptions} - optionLabelProp="title" - disabled={disabled} - className="w-full" - /> - - ) : null} - {hasClaudePermissions ? ( - <> - {/* Caption then peer rail rows (mode / allow / ask / deny) sharing the - section rail — the control renders its own RailField rows. */} - - Claude harness - - - | undefined - )?.default_mode - } - /> - - ) : null} - {hasPiSettings ? ( - <> - - Pi harness - - - - ) : null} - + const permissionsBody = ( + <> + {runnerPermissionSchema ? ( + + + value={currentRunnerPermission} + onChange={(v) => + setSection("runner", { + ...runner, + permissions: {...runnerPermissions, default: v}, + }) + } + options={runnerPermissionOptions} + optionLabelProp="title" + disabled={disabled} + className="w-full" + /> + + ) : null} + {hasClaudePermissions ? ( + <> + {/* Caption then peer rail rows (mode / allow / ask / deny) sharing the + section rail — the control renders its own RailField rows. */} + {focus.active ? null : ( + + Claude harness + + )} + + | undefined + )?.default_mode + } + /> + + ) : null} + {hasPiSettings ? ( + <> + {focus.active ? null : ( + + Pi harness + + )} + {/* Availability, not permission — it declares no config path, so a focus + filter drops it and only the changed permission rows remain. */} + {focus.active ? null : ( + + )} + | null) ?? null} + onChange={(permissions) => setSection("harness", {...harness, permissions})} + disabled={disabled} + /> + ) : null} ) + const advancedControls = ( + <> + {/* Playground-only overlay — it owns no committed property, so a focus filter drops it. */} + {focus.active ? null : buildKitSection} + + {hasExecutionGroup + ? advancedGroup( + { + inFocus: sandboxInFocus, + defaultOpen: sandboxChanged, + indicator: changedIndicator(sandboxChanged), + extra: revertAction(revertSandbox), + icon: , + title: "Execution environment", + summary: sandbox.kind ? `Sandbox: ${String(sandbox.kind)}` : undefined, + caption: ( + + Where the agent's tools and code run, and what that sandbox + may touch. + + ), + }, + executionBody, + ) + : null} + + {hasPermissionsGroup + ? advancedGroup( + { + inFocus: permissionsInFocus, + defaultOpen: permissionsChanged, + indicator: changedIndicator(permissionsChanged), + extra: permissionsChanged ? revertAction(revertPermissions) : undefined, + icon: , + title: "Permissions", + summary: runnerPermissionSummary, + caption: ( + + What the agent may do on its own before it must ask. + + ), + }, + permissionsBody, + ) + : null} + + ) + // The stacked sections carry their own dividers; drop the trailing one on whichever section // renders last (they're conditional, so target the last child rather than a fixed section). - const advancedDrawerBody = ( + // Same as Model & harness: the version-history aside is drawer chrome; under a focus filter this + // body renders inline in the panel, so drop to a single column of the narrowed controls. + const advancedDrawerBody = focus.active ? ( +
+ {advancedControls} +
+ ) : (
{advancedControls} @@ -840,6 +1066,10 @@ export function useModelHarness({ // The selected model's provider has a standard vault slot but no key yet — the config panel // highlights the Model & harness section and the chat gates on it until it's connected. needsProviderKey: providerNeedsKey, + // The compact `bare` credentials pane (mode toggle + selected provider's key form or + // self-managed card; no nested header/badge, no rail) for the inline "Connect key" + // quick-action under the section header — aligned with the drawer without duplicating it. + providerCredentialsInline, // A model is selected but the chosen harness can't run it — a *model* problem (the harness // itself stays valid), so the config panel flags the Model & harness section as invalid. modelUnsupported: !!modelId && !selectedKeepsModel, diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/index.ts b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/index.ts index fabb730ebb..9e2b344826 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/index.ts +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/index.ts @@ -69,6 +69,15 @@ export {ToolSelectorPopover} from "./ToolSelectorPopover" export type {ToolSelectorPopoverProps} from "./ToolSelectorPopover" export {TOOL_PROVIDERS_META, TOOL_SPECS} from "./toolUtils" export type {ToolObj, ToolFunction} from "./toolUtils" +export { + findGrantableTool, + withToolPermission, + gateRulePattern, + readHarnessAllowList, + findGrantableHarnessTool, + withHarnessToolAllow, +} from "./toolPermission" +export type {GrantableTool, ToolPermission, GrantableHarnessTool} from "./toolPermission" export {McpServerItemControl} from "./McpServerItemControl" export type {McpServerItemControlProps} from "./McpServerItemControl" diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/toolPermission.ts b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/toolPermission.ts new file mode 100644 index 0000000000..2d0fac044c --- /dev/null +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/toolPermission.ts @@ -0,0 +1,230 @@ +/** + * toolPermission — locate a runtime tool gate's config entry and set its per-tool permission. + * + * The approval card's "always allow this tool" writes a per-tool `permission` onto the matching + * entry in the agent template's `tools[]` (config write-through: the run reads the draft config, so + * the change takes effect on the in-flight resume and every future run; a commit carries it to + * triggers). Only tools that live in the committed `tools[]` AND carry a per-tool `permission` slot + * are grantable: gateway (Composio) tools and custom function tools. Platform ops (overlay-injected, + * e.g. `commit_revision`), builtins, MCP servers (per-server permission, not per-tool), and workflow + * references are deliberately NOT matched here, so they can never be auto-allowed from the card — + * `commit_revision` and destructive ops stay gated by construction. + * + * The matcher mirrors the runtime wire name: a gateway gate arrives as the slug + * `tools__{provider}__{integration}__{action}__{connection}` (see `parseGatewayToolName`), and a + * custom function tool arrives as its bare `function.name`. + */ +import {parseGatewayToolSlug} from "@agenta/shared/utils" + +import {parseGatewayTool} from "./toolUtils" + +export type ToolPermission = "allow" | "ask" | "deny" + +const isRecord = (v: unknown): v is Record => + Boolean(v && typeof v === "object" && !Array.isArray(v)) + +const asPermission = (v: unknown): ToolPermission | undefined => + v === "allow" || v === "ask" || v === "deny" ? v : undefined + +interface TemplateLocation { + template: Record + /** Rebuild the full `parameters` object from an updated template. */ + wrap: (nextTemplate: Record) => Record +} + +/** + * The agent template lives at `parameters.agent` (the playground shape) or IS the parameters (a bare + * template). Mirror `buildAgentRequest`'s `withAgentRunDefaults` so a write lands exactly where the + * run reads from. + */ +function locateTemplate(parameters: Record): TemplateLocation { + if (isRecord(parameters.agent)) { + const agent = parameters.agent + return {template: agent, wrap: (next) => ({...parameters, agent: next})} + } + return {template: parameters, wrap: (next) => next} +} + +/** + * Index of the `tools[]` entry a runtime gate `toolName` refers to, or -1. A gateway tool (canonical + * `{type:"gateway"}` or a legacy function-name slug) matches by its {provider, integration, action, + * connection} identity; a custom function tool matches by `function.name`. + */ +function matchToolIndex(tools: unknown[], toolName: string): number { + const slug = parseGatewayToolSlug(toolName) + for (let i = 0; i < tools.length; i++) { + const entry = tools[i] + if (!isRecord(entry)) continue + if (slug) { + const g = parseGatewayTool(entry) + if ( + g && + g.provider === slug.provider && + g.integration === slug.integration && + g.action === slug.action && + g.connection === slug.connection + ) { + return i + } + continue + } + const fn = isRecord(entry.function) ? entry.function : null + if (fn && typeof fn.name === "string" && fn.name === toolName) return i + } + return -1 +} + +export interface GrantableTool { + /** The current per-tool permission on the matched entry, if one is set. */ + permission?: ToolPermission +} + +// --------------------------------------------------------------------------- +// Harness tools (bash / Terminal / Write / …): the `harness.permissions.allow` path +// --------------------------------------------------------------------------- +// +// A harness tool carries NO per-tool `permission` (a builtin's own permission is dropped as +// unenforceable). The only lever is an authored allow-rule: `harness.permissions.allow += []` flows through `wire_author_permission_rules` into a runner rule +// `{pattern, permission:"allow"}`, while `runner.permissions.default` stays as authored so platform +// ops (commit_revision, schedules) keep gating. Works on Pi AND Claude — `_parse_harness_slice` +// reads `harness.permissions` for any harness. +// +// THE PATTERN IS THE GATE NAME, VERBATIM. The runner matches rules with `pattern === gate.toolName` +// (permission-plan.ts `ruleMatches`), and `gate.toolName` is exactly what the approval card shows: +// the runner stamps it onto the gate as `resolvedName` (acp-interactions.ts) and the SDK's +// `_approval_tool_name` prefers that field for the part. Do NOT "canonicalize" the name — an ACP +// gate reports `bash`/`Terminal` verbatim (buildGateDescriptor: `spec?.name ?? displayName`), so a +// rule for `Bash` would silently never match. + +/** Platform ops (overlay-injected). These must ALWAYS gate — never auto-allowable from the card. */ +const PLATFORM_OPS = new Set([ + "discover_tools", + "query_workflows", + "query_spans", + "test_run", + "commit_revision", + "annotate_trace", + "discover_triggers", + "create_schedule", + "create_subscription", + "list_schedules", + "list_subscriptions", + "list_deliveries", + "list_connections", + "test_subscription", + "remove_schedule", + "remove_subscription", + "pause_schedule", + "resume_schedule", + "pause_subscription", + "resume_subscription", +]) + +/** Browser-fulfilled client tools — they carry their own widget/decline UI; never auto-allowable. */ +const CLIENT_TOOLS = new Set(["request_connection", "request_input"]) + +/** + * The runner rule pattern for a gate, or `null` when the gate must never be auto-allowed: + * a platform op, a client tool, or an MCP tool — `wire_author_permission_rules` DROPS `mcp__` + * patterns from the runner plan, so such a rule would silently never take effect (MCP is governed + * per-server instead). + */ +export function gateRulePattern(toolName: string): string | null { + if (!toolName) return null + if (PLATFORM_OPS.has(toolName) || CLIENT_TOOLS.has(toolName)) return null + if (toolName.startsWith("mcp__")) return null + return toolName +} + +/** The authored `harness.permissions.allow` patterns. */ +export function readHarnessAllowList(parameters: unknown): string[] { + if (!isRecord(parameters)) return [] + const {template} = locateTemplate(parameters) + const harness = isRecord(template.harness) ? template.harness : {} + const permissions = isRecord(harness.permissions) ? harness.permissions : {} + return Array.isArray(permissions.allow) + ? (permissions.allow.filter((v) => typeof v === "string") as string[]) + : [] +} + +export interface GrantableHarnessTool { + /** The runner rule pattern — the gate name, verbatim. */ + pattern: string + /** Already present in `harness.permissions.allow`. */ + allowed: boolean +} + +/** + * Classify a gate as an allow-rule-able harness tool and report whether it's already allowed, or + * `null` when it must stay gated. Callers must check `findGrantableTool` FIRST: a gateway/custom + * tool has a `tools[]` entry whose per-tool `permission` outranks any rule. + */ +export function findGrantableHarnessTool( + parameters: unknown, + toolName: string, +): GrantableHarnessTool | null { + const pattern = gateRulePattern(toolName) + if (!pattern || !isRecord(parameters)) return null + return {pattern, allowed: readHarnessAllowList(parameters).includes(pattern)} +} + +/** Return a new `parameters` with `pattern` present (or absent, when `allowed` is false) in + * `harness.permissions.allow`. Preserves the rest of the harness/permissions object. */ +export function withHarnessToolAllow( + parameters: unknown, + pattern: string, + allowed: boolean, +): Record | null { + if (!isRecord(parameters) || !pattern) return null + const {template, wrap} = locateTemplate(parameters) + const harness = isRecord(template.harness) ? {...template.harness} : {} + const permissions = isRecord(harness.permissions) ? {...harness.permissions} : {} + const current = Array.isArray(permissions.allow) + ? (permissions.allow.filter((v) => typeof v === "string") as string[]) + : [] + const has = current.includes(pattern) + if (allowed === has) return wrap({...template, harness: {...harness, permissions}}) + const nextAllow = allowed ? [...current, pattern] : current.filter((name) => name !== pattern) + return wrap({ + ...template, + harness: {...harness, permissions: {...permissions, allow: nextAllow}}, + }) +} + +/** + * Find the grantable `tools[]` entry for a gate, or `null` when the gate is not a per-tool-config + * tool (platform op, builtin, MCP, reference, or an unknown name). A `null` result is the signal to + * hide the "always allow" affordance. + */ +export function findGrantableTool(parameters: unknown, toolName: string): GrantableTool | null { + if (!isRecord(parameters) || !toolName) return null + const {template} = locateTemplate(parameters) + const tools = Array.isArray(template.tools) ? (template.tools as unknown[]) : [] + const i = matchToolIndex(tools, toolName) + if (i < 0) return null + return {permission: asPermission((tools[i] as Record).permission)} +} + +/** + * Return a new `parameters` with the gate's tool entry set to `permission` (or its `permission` key + * removed when `undefined` = inherit). Returns `null` when the gate is not grantable, so the caller + * leaves the config untouched. + */ +export function withToolPermission( + parameters: unknown, + toolName: string, + permission: ToolPermission | undefined, +): Record | null { + if (!isRecord(parameters) || !toolName) return null + const {template, wrap} = locateTemplate(parameters) + const tools = Array.isArray(template.tools) ? (template.tools as unknown[]) : [] + const i = matchToolIndex(tools, toolName) + if (i < 0) return null + const entry = isRecord(tools[i]) ? {...(tools[i] as Record)} : {} + if (permission === undefined) delete entry.permission + else entry.permission = permission + const nextTools = tools.slice() + nextTools[i] = entry + return wrap({...template, tools: nextTools}) +} diff --git a/web/packages/agenta-entity-ui/src/DrillInView/index.ts b/web/packages/agenta-entity-ui/src/DrillInView/index.ts index f489604117..4a2aba8cba 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/index.ts +++ b/web/packages/agenta-entity-ui/src/DrillInView/index.ts @@ -267,6 +267,12 @@ export { normalizeMessages, denormalizeMessages, getOptionsFromSchema, + findGrantableTool, + withToolPermission, + gateRulePattern, + readHarnessAllowList, + findGrantableHarnessTool, + withHarnessToolAllow, type OptionGroup, } from "./SchemaControls" @@ -291,6 +297,9 @@ export type { ObjectSchemaControlProps, SchemaPropertyRendererProps, FieldsDetectionContextValue, + GrantableTool, + ToolPermission, + GrantableHarnessTool, } from "./SchemaControls" // Operational panel regions (Triggers, Mounts) — siblings of the Configuration section. diff --git a/web/packages/agenta-entity-ui/src/drawers/shared/ChangedPathsContext.tsx b/web/packages/agenta-entity-ui/src/drawers/shared/ChangedPathsContext.tsx new file mode 100644 index 0000000000..a0a496690f --- /dev/null +++ b/web/packages/agenta-entity-ui/src/drawers/shared/ChangedPathsContext.tsx @@ -0,0 +1,109 @@ +/** + * ChangedPathsContext + * + * Carries "which config properties have uncommitted changes" — as dot-paths like + * `harness.permissions.allow` / `runner.permissions.default` / `sandbox.kind` — down to the rail + * rows that render them. A drawer can then mark the exact property row that changed and open the + * sub-section that holds it, without prop-drilling a path map through a large render. + * + * Deliberately structural and dependency-free: it knows nothing about the agent template or the + * commit-diff classifier, so this shared drawer primitive stays usable by any config surface. The + * agent panel's `SectionChanges` (see `SchemaControls/agentTemplate/sectionChanges.ts`) satisfies + * `ChangedPaths` by shape and can be passed straight in. + * + * With no provider, nothing is marked — every existing caller keeps working untouched. + */ +import {createContext, useContext, useMemo, type ReactNode} from "react" + +export interface ChangedPaths { + /** Whether this exact property path changed. */ + isChanged: (path: string) => boolean + /** Whether anything under this dotted subtree changed. */ + hasChangedUnder: (prefix: string) => boolean + /** Changed paths under a subtree (all of them with no prefix) — the input to a scoped revert. */ + pathsUnder: (prefix?: string) => string[] + /** + * What this property changed FROM — so a row can answer "changed, but from what?" without the + * reader opening a commit diff. Pre-formatted display strings (the classifier already renders + * them); `before: undefined` means the committed config had no value for it. + */ + changeFor?: (path: string) => {before?: string; after?: string} | undefined + /** + * Restore these paths to their committed values. Supplied by the HOST, because where the write + * lands differs by surface: inside a section drawer it must go through that drawer's scoped + * draft (so Cancel/Save still mean what they say), while the panel writes the entity draft + * directly. Absent = the surface offers no revert, and the affordance stays hidden. + */ + revert?: (paths: string[]) => void +} + +const NONE: ChangedPaths = { + isChanged: () => false, + hasChangedUnder: () => false, + pathsUnder: () => [], +} + +const ChangedPathsContext = createContext(NONE) + +export function ChangedPathsProvider({ + changes, + children, +}: { + changes: ChangedPaths + children: ReactNode +}) { + return {children} +} + +/** Whether this exact property path has an uncommitted change (marks one rail row). */ +export function useChangedPath(path: string | undefined): boolean { + const changes = useContext(ChangedPathsContext) + return useMemo(() => (path ? changes.isChanged(path) : false), [changes, path]) +} + +/** + * What this property changed from → to, when the surface can say. Null when it's unchanged, so a + * caller can render the row's "changed" affordance and its explanation from one lookup. + */ +export function useChangedDetail( + path: string | undefined, +): {before?: string; after?: string} | null { + const changes = useContext(ChangedPathsContext) + return useMemo(() => { + if (!path || !changes.isChanged(path)) return null + return changes.changeFor?.(path) ?? null + }, [changes, path]) +} + +/** + * Revert ONE property, for a key-scoped undo on its row. Null when the path is unchanged or the + * surface offers no revert, so the caller renders a plain marker instead of an action. + */ +export function useRevertPath(path: string | undefined): (() => void) | null { + const changes = useContext(ChangedPathsContext) + return useMemo(() => { + const {revert, isChanged} = changes + if (!revert || !path || !isChanged(path)) return null + return () => revert([path]) + }, [changes, path]) +} + +/** Whether anything under this dotted subtree changed — drives a sub-section's `defaultOpen`. */ +export function useHasChangedUnder(prefix: string | undefined): boolean { + const changes = useContext(ChangedPathsContext) + return useMemo(() => (prefix ? changes.hasChangedUnder(prefix) : false), [changes, prefix]) +} + +/** + * Revert a subtree, for a section-scoped "undo these changes" action. Returns null when the surface + * offers no revert or the subtree is unchanged, so the caller renders nothing. + */ +export function useRevertUnder(prefix: string | undefined): (() => void) | null { + const changes = useContext(ChangedPathsContext) + return useMemo(() => { + const {revert, pathsUnder} = changes + if (!revert || !prefix) return null + const paths = pathsUnder(prefix) + return paths.length ? () => revert(paths) : null + }, [changes, prefix]) +} diff --git a/web/packages/agenta-entity-ui/src/drawers/shared/FocusPathsContext.tsx b/web/packages/agenta-entity-ui/src/drawers/shared/FocusPathsContext.tsx new file mode 100644 index 0000000000..65fc591dc6 --- /dev/null +++ b/web/packages/agenta-entity-ui/src/drawers/shared/FocusPathsContext.tsx @@ -0,0 +1,74 @@ +/** + * FocusPathsContext + * + * Narrows a config surface to just the properties that matter right now — the section's REAL + * controls, filtered, rather than a second rendering of them. + * + * This is the general form of the Model & harness "Connect key" affordance: when something needs + * attention, show the control that owns it and link out for the rest. "Needs a key" and "changed + * since the commit" are then the same pattern with different filters, over one set of controls. + * + * Mechanism: rows already declare their `path` (see {@link RailField}), so a filter is all that's + * needed — a focused row renders itself, an unfocused one renders nothing, and the group that owns + * no focused path hides. No parallel "what changed" UI to build or keep in sync. + * + * Inactive by default (no provider = everything renders), so the drawers and every other host are + * untouched. + */ +import {createContext, useContext, useMemo, type ReactNode} from "react" + +export interface FocusPaths { + /** A filter is in force — rows and groups outside it hide. */ + active: boolean + /** Whether this exact property is in focus. */ + isFocused: (path: string) => boolean + /** Whether this dotted subtree contains anything in focus (does this group survive?). */ + hasFocusUnder: (prefix: string) => boolean +} + +/** No filter: everything renders. */ +const NONE: FocusPaths = {active: false, isFocused: () => true, hasFocusUnder: () => true} + +const FocusPathsContext = createContext(NONE) + +export function FocusPathsProvider({ + paths, + children, +}: { + /** The properties to narrow to. `null` = no filter (render everything). */ + paths: string[] | null + children: ReactNode +}) { + const value = useMemo(() => { + if (!paths) return NONE + const set = new Set(paths) + return { + active: true, + isFocused: (path) => set.has(path), + hasFocusUnder: (prefix) => + [...set].some((path) => path === prefix || path.startsWith(`${prefix}.`)), + } + }, [paths]) + return {children} +} + +/** The active filter — for a caller that needs to branch on it (e.g. flat vs grouped chrome). */ +export function useFocusPaths(): FocusPaths { + return useContext(FocusPathsContext) +} + +/** Whether a row should render: true unless a filter is in force that excludes it. A row with no + * `path` can't be matched, so it hides under a filter rather than leaking in as noise. */ +export function useIsPathVisible(path: string | undefined): boolean { + const focus = useContext(FocusPathsContext) + return useMemo(() => !focus.active || (!!path && focus.isFocused(path)), [focus, path]) +} + +/** Whether a group survives the filter — i.e. it owns at least one focused property. */ +export function useHasFocusUnder(prefix: string | undefined): boolean { + const focus = useContext(FocusPathsContext) + return useMemo( + () => !focus.active || (!!prefix && focus.hasFocusUnder(prefix)), + [focus, prefix], + ) +} diff --git a/web/packages/agenta-entity-ui/src/drawers/shared/RailField.tsx b/web/packages/agenta-entity-ui/src/drawers/shared/RailField.tsx index c69a410bc4..afdc43b459 100644 --- a/web/packages/agenta-entity-ui/src/drawers/shared/RailField.tsx +++ b/web/packages/agenta-entity-ui/src/drawers/shared/RailField.tsx @@ -11,13 +11,23 @@ */ import type {ReactNode} from "react" -import {Info} from "@phosphor-icons/react" -import {Tooltip} from "antd" +import {ArrowCounterClockwise, Info} from "@phosphor-icons/react" +import {Button, Popover, Tooltip} from "antd" + +import {useChangedDetail, useChangedPath, useRevertPath} from "./ChangedPathsContext" +import {useIsPathVisible} from "./FocusPathsContext" export interface RailFieldProps { label: ReactNode /** Vertical alignment of the label against the content. @default "top" */ align?: "top" | "center" + /** + * This row's config dot-path (e.g. `runner.permissions.default`). When it has an uncommitted + * change — per the surrounding {@link ChangedPathsProvider} — the label marks itself and opens + * the change's detail, so a surface shows WHICH property changed rather than just that + * something did. Opt-in: without a path (or a provider) the row renders exactly as before. + */ + path?: string children: ReactNode } @@ -34,15 +44,98 @@ export const railInfoLabel = (label: ReactNode, hint: ReactNode): ReactNode => ( ) -export function RailField({label, align = "top", children}: RailFieldProps) { +// Unpack the classifier's JSON.stringify'd scalar back to what the field shows (one rule per line), +// naming the empty cases rather than printing wire syntax like `[]`. +export function formatCommitted(before: string | undefined): {text: string; muted: boolean} { + if (before === undefined) return {text: "Not set", muted: true} + let value: unknown = before + try { + value = JSON.parse(before) + } catch { + // A plain string the classifier passed through as-is. + } + if (Array.isArray(value)) { + return value.length + ? {text: value.map((entry) => String(entry)).join("\n"), muted: false} + : {text: "Empty", muted: true} + } + if (value && typeof value === "object") { + return Object.keys(value).length + ? {text: JSON.stringify(value, null, 2), muted: false} + : {text: "Empty", muted: true} + } + const text = String(value ?? "") + return text.trim() ? {text, muted: false} : {text: "Empty", muted: true} +} + +// The committed value ("changed from what?") and its undo on one surface: the value shown IS the +// revert's confirmation, so no separate confirm step is needed. +function ChangedDetail({ + before, + onRevert, +}: { + before: string | undefined + onRevert: (() => void) | null +}) { + const {text, muted} = formatCommitted(before) + return ( +
+
+ Committed value +
+
+ {text} +
+ {onRevert ? ( + + ) : null} +
+ ) +} + +export function RailField({label, align = "top", path, children}: RailFieldProps) { + const changed = useChangedPath(path) + const detail = useChangedDetail(path) + const revert = useRevertPath(path) + // Focus filter (see FocusPathsContext): each row self-filters on its own `path`. + const visible = useIsPathVisible(path) + if (!visible) return null return (
- {label} + {/* The label carries the change via emphasis (colorTextSecondary → colorText) plus a + colorInfo dotted underline — no marker glyph, so changed and unchanged rows share + the same box. */} + {changed ? ( + } + > + + {label} + + + ) : ( + label + )}
{children} diff --git a/web/packages/agenta-entity-ui/src/drawers/shared/index.ts b/web/packages/agenta-entity-ui/src/drawers/shared/index.ts index 85b73ab750..08a863e456 100644 --- a/web/packages/agenta-entity-ui/src/drawers/shared/index.ts +++ b/web/packages/agenta-entity-ui/src/drawers/shared/index.ts @@ -6,6 +6,28 @@ export {RailField, railInfoLabel, type RailFieldProps} from "./RailField" export {SectionRail, type SectionRailItem, type SectionRailProps} from "./SectionRail" +// "Which properties have uncommitted changes" — lets a rail row mark the exact changed property and +// a sub-section open itself when it owns one. Structural, so any config surface can provide it. +export { + ChangedPathsProvider, + useChangedDetail, + useChangedPath, + useHasChangedUnder, + useRevertPath, + useRevertUnder, + type ChangedPaths, +} from "./ChangedPathsContext" + +// Narrow a surface to the properties that matter right now — the real controls, filtered, rather +// than a second rendering of them. Rows self-filter on the `path` they already declare. +export { + FocusPathsProvider, + useFocusPaths, + useIsPathVisible, + useHasFocusUnder, + type FocusPaths, +} from "./FocusPathsContext" + // Grouped-section primitives (uppercase sub-headers + collapsible provider cards) shared with the // config panel's Tools/Triggers sections, so app-layer previews render identical provider groups. export { diff --git a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerDeliveriesDrawer.tsx b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerDeliveriesDrawer.tsx index 259f011ed4..6be9ffd170 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerDeliveriesDrawer.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerDeliveriesDrawer.tsx @@ -257,7 +257,7 @@ export default function TriggerDeliveriesDrawer() { setState(null) }} title={`Deliveries${state?.name ? ` · ${state.name}` : ""}`} - width={820} + size={820} destroyOnClose styles={{ body: {padding: 0, display: "flex", flexDirection: "column", overflow: "hidden"}, diff --git a/web/packages/agenta-entity-ui/tests/unit/formatCommitted.test.ts b/web/packages/agenta-entity-ui/tests/unit/formatCommitted.test.ts new file mode 100644 index 0000000000..04ff2d1361 --- /dev/null +++ b/web/packages/agenta-entity-ui/tests/unit/formatCommitted.test.ts @@ -0,0 +1,51 @@ +/** + * Unit tests for `formatCommitted`, the pure helper behind a changed rail row's "committed value" + * popover. + * + * It exists because the two sides disagree on what a value looks like: the commit-diff classifier + * stores scalars through `JSON.stringify` (`commitDiff/classify.ts` → `fmtScalar`) because it is + * COMPARING them, while the popover is SHOWING them to a person. Left unformatted that leaked wire + * syntax into the UI — an untouched allow-list rendered as a bare `[]`. These pin the unwrapping, + * and that the empty/absent cases get named rather than printing punctuation at the reader. + * + * Runs under @agenta/entity-ui's vitest runner. + */ +import {describe, expect, it} from "vitest" + +import {formatCommitted} from "../../src/drawers/shared/RailField" + +describe("formatCommitted — the classifier's wire value, as a reader sees it", () => { + it("names the absent and empty cases instead of showing their punctuation", () => { + // `[]` is what the popover actually leaked before this existed. + expect(formatCommitted(undefined)).toEqual({text: "Not set", muted: true}) + expect(formatCommitted("[]")).toEqual({text: "Empty", muted: true}) + expect(formatCommitted("{}")).toEqual({text: "Empty", muted: true}) + expect(formatCommitted('""')).toEqual({text: "Empty", muted: true}) + expect(formatCommitted(" ")).toEqual({text: "Empty", muted: true}) + }) + + it("unwraps a stringified list to one entry per line, matching the control that renders it", () => { + // The allow-rules field is a textarea of one rule per line — not `["Terminal","Write"]`. + expect(formatCommitted('["Terminal","Write"]')).toEqual({ + text: "Terminal\nWrite", + muted: false, + }) + }) + + it("keeps a plain scalar as itself", () => { + expect(formatCommitted("ask")).toEqual({text: "ask", muted: false}) + expect(formatCommitted("42")).toEqual({text: "42", muted: false}) + }) + + it("pretty-prints a non-empty object rather than showing one long line", () => { + expect(formatCommitted('{"mode":"deny"}')).toEqual({ + text: '{\n "mode": "deny"\n}', + muted: false, + }) + }) + + it("passes through a string that only looks like JSON, without throwing", () => { + // fmtScalar sends non-objects through `String(v)`, so an instruction can reach here unquoted. + expect(formatCommitted("{not json")).toEqual({text: "{not json", muted: false}) + }) +}) diff --git a/web/packages/agenta-entity-ui/tests/unit/sectionChanges.test.ts b/web/packages/agenta-entity-ui/tests/unit/sectionChanges.test.ts new file mode 100644 index 0000000000..a92e123749 --- /dev/null +++ b/web/packages/agenta-entity-ui/tests/unit/sectionChanges.test.ts @@ -0,0 +1,236 @@ +/** + * Pins the CONTRACT between the commit-diff classifier and the config panel/drawer. + * + * The drawer marks a changed property by hardcoding its dot-path on a `RailField` + * (`path="harness.permissions.allow"`), and sub-sections open via `hasChangedUnder("harness")`. + * Those strings only work if they match what `classifyAgentChanges` actually flattens to — and a + * mismatch fails SILENTLY (no mark, no error), so it must be asserted rather than assumed. + * + * These tests therefore diff realistic before/after templates the same way the panel does (bare + * agent template on both sides) and assert the emitted `scalarChanges` keys verbatim. + */ +import {classifyAgentChanges} from "@agenta/entities/workflow/commitDiff" +import {describe, expect, it} from "vitest" + +import { + revertPathsTo, + SECTION_ID_TO_PANEL_KEY, + toSectionChanges, +} from "../../src/DrillInView/SchemaControls/agentTemplate/sectionChanges" + +/** The bare agent template both sides of the panel's diff use (`parameters.agent`, unwrapped). */ +const template = (overrides: Record = {}) => ({ + instructions: {agents_md: "You are a friendly hello-world agent."}, + llm: {model: "gpt-5", provider: "openai"}, + tools: [], + harness: {kind: "claude"}, + runner: {permissions: {default: "allow_reads"}}, + sandbox: {kind: "local"}, + ...overrides, +}) + +const changesFor = (local: Record, remote: Record) => + toSectionChanges(classifyAgentChanges(local, remote)) + +describe("SECTION_ID_TO_PANEL_KEY", () => { + it("maps every classifier section id to a panel key", () => { + expect(SECTION_ID_TO_PANEL_KEY).toEqual({ + model: "model-harness", + instructions: "instructions", + tools: "tools", + mcps: "mcp", + skills: "skills", + params: "advanced", + }) + }) +}) + +describe("changed paths — the strings the drawer hardcodes", () => { + it("an approval grant to harness.permissions.allow lands on the Advanced section at that exact path", () => { + const committed = template() + const draft = template({harness: {kind: "claude", permissions: {allow: ["Terminal"]}}}) + const changes = changesFor(draft, committed) + + expect(changes.panelKeys.has("advanced")).toBe(true) + expect(changes.changedPaths.has("harness.permissions.allow")).toBe(true) + expect(changes.isChanged("harness.permissions.allow")).toBe(true) + }) + + // The row's popover answers "changed from what?" with `before` — so a change the classifier + // reports MUST also be retrievable by its path, or the row marks itself and then has nothing to + // say. `before: undefined` is meaningful (the commit didn't set it) and must survive as itself. + it("recalls what a changed property was committed as, by path", () => { + const changes = changesFor( + template({runner: {permissions: {default: "allow"}}}), + template({runner: {permissions: {default: "ask"}}}), + ) + + expect(changes.changeFor("runner.permissions.default")?.before).toBe("ask") + expect(changes.changeFor("runner.permissions.default")?.after).toBe("allow") + expect(changes.changeFor("sandbox.kind")).toBeUndefined() + }) + + it("reports a property the commit never had as added, with no before value", () => { + const changes = changesFor( + template({harness: {kind: "claude", permissions: {allow: ["Terminal"]}}}), + template(), + ) + const change = changes.changeFor("harness.permissions.allow") + + expect(change).toBeDefined() + expect(change?.before).toBeUndefined() + }) + + it("opens the Permissions group (harness/runner) but not Execution environment (sandbox)", () => { + const committed = template() + const draft = template({harness: {kind: "claude", permissions: {allow: ["Terminal"]}}}) + const changes = changesFor(draft, committed) + + expect(changes.hasChangedUnder("harness")).toBe(true) + expect(changes.hasChangedUnder("runner")).toBe(false) + expect(changes.hasChangedUnder("sandbox")).toBe(false) + }) + + it("emits runner.permissions.default for a Policy change", () => { + const committed = template() + const draft = template({runner: {permissions: {default: "allow"}}}) + const changes = changesFor(draft, committed) + + expect(changes.changedPaths.has("runner.permissions.default")).toBe(true) + expect(changes.hasChangedUnder("runner")).toBe(true) + }) + + it("emits sandbox.kind and the nested sandbox.permissions.* paths", () => { + const committed = template() + const draft = template({ + sandbox: { + kind: "daytona", + permissions: {network: {mode: "off"}, filesystem: "readonly"}, + }, + }) + const changes = changesFor(draft, committed) + + expect(changes.changedPaths.has("sandbox.kind")).toBe(true) + expect(changes.changedPaths.has("sandbox.permissions.network.mode")).toBe(true) + expect(changes.changedPaths.has("sandbox.permissions.filesystem")).toBe(true) + expect(changes.hasChangedUnder("sandbox")).toBe(true) + }) + + it("keeps harness.kind on Model & harness, NOT Advanced (the buckets are split there)", () => { + const committed = template() + const draft = template({harness: {kind: "pi_core"}}) + const changes = changesFor(draft, committed) + + expect(changes.panelKeys.has("model-harness")).toBe(true) + expect(changes.panelKeys.has("advanced")).toBe(false) + expect(changes.changedPaths.has("harness.kind")).toBe(true) + }) + + it("reports nothing changed for an identical template", () => { + const changes = changesFor(template(), template()) + expect(changes.panelKeys.size).toBe(0) + expect(changes.changedPaths.size).toBe(0) + expect(changes.hasChangedUnder("harness")).toBe(false) + }) +}) + +describe("revertPathsTo", () => { + it("restores a changed value to the committed one, and the diff then reports clean", () => { + const committed = template() + const draft = template({runner: {permissions: {default: "allow"}}}) + + const reverted = revertPathsTo(draft, committed, ["runner.permissions.default"]) + + expect(reverted).toEqual(committed) + expect(changesFor(reverted, committed).changedPaths.size).toBe(0) + }) + + it("DELETES a key the commit never had (an added property), pruning the empty slice it leaves", () => { + const committed = template() // harness: {kind: "claude"} — no `permissions` + const draft = template({harness: {kind: "claude", permissions: {allow: ["Terminal"]}}}) + + const reverted = revertPathsTo(draft, committed, ["harness.permissions.allow"]) + + // Not `permissions: {}` left behind — the slice is pruned, so the diff is truly clean. + expect(reverted.harness).toEqual({kind: "claude"}) + expect(changesFor(reverted, committed).changedPaths.size).toBe(0) + }) + + it("reverts only the named path, leaving other changes intact (key-scoped undo)", () => { + const committed = template() + const draft = template({ + harness: {kind: "claude", permissions: {allow: ["Terminal"]}}, + runner: {permissions: {default: "allow"}}, + }) + + const reverted = revertPathsTo(draft, committed, ["harness.permissions.allow"]) + const changes = changesFor(reverted, committed) + + expect(changes.changedPaths.has("harness.permissions.allow")).toBe(false) + expect(changes.changedPaths.has("runner.permissions.default")).toBe(true) + }) + + it("reverts a whole subtree from pathsUnder (section-scoped undo)", () => { + const committed = template() + const draft = template({ + sandbox: {kind: "daytona", permissions: {filesystem: "readonly"}}, + }) + const paths = changesFor(draft, committed).pathsUnder("sandbox") + + const reverted = revertPathsTo(draft, committed, paths) + + expect(reverted).toEqual(committed) + }) + + it("restores an array leaf whole, and never mutates the input", () => { + const committed = template({harness: {kind: "claude", permissions: {allow: ["Read"]}}}) + const draft = template({ + harness: {kind: "claude", permissions: {allow: ["Read", "Terminal"]}}, + }) + const snapshot = JSON.stringify(draft) + + const reverted = revertPathsTo(draft, committed, ["harness.permissions.allow"]) as { + harness: {permissions: {allow: string[]}} + } + + expect(reverted.harness.permissions.allow).toEqual(["Read"]) + expect(JSON.stringify(draft)).toBe(snapshot) + }) + + it("is a no-op without a committed baseline (a never-saved draft has nothing to revert to)", () => { + const draft = template() + expect(revertPathsTo(draft, null, ["runner.permissions.default"])).toBe(draft) + }) +}) + +describe("hasChangedUnder", () => { + it("matches the subtree, not a same-prefixed sibling key", () => { + const changes = toSectionChanges([ + { + id: "params", + title: "Advanced", + tags: [], + totalCount: 1, + scalarChanges: [ + {key: "harnessing.thing", before: "a", after: "b", kind: "changed"}, + ], + }, + ]) + // "harnessing.thing" must NOT make the `harness` group look changed. + expect(changes.hasChangedUnder("harness")).toBe(false) + expect(changes.hasChangedUnder("harnessing")).toBe(true) + }) + + it("treats an exact path as changed under itself", () => { + const changes = toSectionChanges([ + { + id: "params", + title: "Advanced", + tags: [], + totalCount: 1, + scalarChanges: [{key: "harness", before: "a", after: "b", kind: "changed"}], + }, + ]) + expect(changes.hasChangedUnder("harness")).toBe(true) + }) +}) diff --git a/web/packages/agenta-entity-ui/tests/unit/toolPermission.test.ts b/web/packages/agenta-entity-ui/tests/unit/toolPermission.test.ts new file mode 100644 index 0000000000..6c61813d88 --- /dev/null +++ b/web/packages/agenta-entity-ui/tests/unit/toolPermission.test.ts @@ -0,0 +1,224 @@ +/** + * Unit tests for the approval-card "always allow this tool" config write-through. + * + * `findGrantableTool` / `withToolPermission` map a runtime gate's wire `toolName` back to its entry + * in the agent template's `tools[]` and set a per-tool `permission`. Only gateway (canonical or + * legacy slug) and custom function tools are grantable; platform ops, builtins, MCP, and references + * must be left ungrantable so `commit_revision` and destructive ops stay gated. Runs under + * @agenta/entity-ui's own vitest runner. + */ +import {describe, expect, it} from "vitest" + +import { + findGrantableHarnessTool, + findGrantableTool, + gateRulePattern, + readHarnessAllowList, + withHarnessToolAllow, + withToolPermission, +} from "../../src/DrillInView/SchemaControls/toolPermission" + +const GATEWAY_SLUG = "tools__composio__gmail__GMAIL_SEND_EMAIL__conn1" + +const canonicalGateway = (extra: Record = {}) => ({ + type: "gateway", + provider: "composio", + integration: "gmail", + action: "GMAIL_SEND_EMAIL", + connection: "conn1", + ...extra, +}) + +const legacyGateway = (extra: Record = {}) => ({ + type: "function", + function: {name: GATEWAY_SLUG}, + ...extra, +}) + +const customFn = (name: string, extra: Record = {}) => ({ + type: "function", + function: {name, parameters: {type: "object", properties: {}}}, + ...extra, +}) + +const wrap = (tools: unknown[]) => ({ + agent: {tools, runner: {permissions: {default: "allow_reads"}}}, +}) + +describe("findGrantableTool", () => { + it("matches a canonical gateway entry by its {provider,integration,action,connection} identity", () => { + const params = wrap([canonicalGateway()]) + expect(findGrantableTool(params, GATEWAY_SLUG)).toEqual({permission: undefined}) + }) + + it("matches a legacy gateway function-name slug", () => { + const params = wrap([legacyGateway()]) + expect(findGrantableTool(params, GATEWAY_SLUG)).not.toBeNull() + }) + + it("matches a custom function tool by function.name", () => { + const params = wrap([customFn("get_weather")]) + expect(findGrantableTool(params, "get_weather")).toEqual({permission: undefined}) + }) + + it("reports the current permission when one is set", () => { + const params = wrap([canonicalGateway({permission: "allow"})]) + expect(findGrantableTool(params, GATEWAY_SLUG)).toEqual({permission: "allow"}) + }) + + it("does not match a platform op, builtin, reference, or unknown gate", () => { + const params = wrap([ + {type: "platform", op: "commit_revision"}, + {type: "builtin", name: "read"}, + {type: "reference", name: "some_workflow"}, + ]) + expect(findGrantableTool(params, "commit_revision")).toBeNull() + expect(findGrantableTool(params, "read")).toBeNull() + expect(findGrantableTool(params, "mcp__linear__create_issue")).toBeNull() + }) + + it("returns null for a missing config / empty tools", () => { + expect(findGrantableTool(null, GATEWAY_SLUG)).toBeNull() + expect(findGrantableTool(wrap([]), GATEWAY_SLUG)).toBeNull() + }) +}) + +describe("withToolPermission", () => { + it("sets permission on the matched entry and leaves the others untouched", () => { + const other = customFn("keep_me") + const params = wrap([canonicalGateway(), other]) + const next = withToolPermission(params, GATEWAY_SLUG, "allow") as { + agent: {tools: Record[]} + } + expect(next.agent.tools[0].permission).toBe("allow") + expect(next.agent.tools[1]).toEqual(other) + }) + + it("removes the permission key when inheriting (undefined)", () => { + const params = wrap([canonicalGateway({permission: "allow"})]) + const next = withToolPermission(params, GATEWAY_SLUG, undefined) as { + agent: {tools: Record[]} + } + expect("permission" in next.agent.tools[0]).toBe(false) + }) + + it("returns null (no write) for an ungrantable gate", () => { + const params = wrap([{type: "platform", op: "commit_revision"}]) + expect(withToolPermission(params, "commit_revision", "allow")).toBeNull() + }) + + it("does not mutate the input parameters", () => { + const params = wrap([canonicalGateway()]) + const snapshot = JSON.stringify(params) + withToolPermission(params, GATEWAY_SLUG, "allow") + expect(JSON.stringify(params)).toBe(snapshot) + }) + + it("supports a bare template (no agent wrapper)", () => { + const bare = {tools: [canonicalGateway()]} + const next = withToolPermission(bare, GATEWAY_SLUG, "allow") as { + tools: Record[] + } + expect(next.tools[0].permission).toBe("allow") + }) +}) + +const wrapHarness = (permissions?: Record) => ({ + agent: { + tools: [], + runner: {permissions: {default: "allow_reads"}}, + harness: {kind: "pi_agenta", ...(permissions ? {permissions} : {})}, + }, +}) + +describe("gateRulePattern", () => { + // The runner matches `pattern === gate.toolName`, and the card shows that exact string + // (stamped as `resolvedName`). Canonicalizing would silently never match — an ACP gate + // reports `bash`/`Terminal` verbatim, so a rule for `Bash` would be a no-op. + it("returns the gate name VERBATIM — never canonicalized", () => { + expect(gateRulePattern("bash")).toBe("bash") + expect(gateRulePattern("Terminal")).toBe("Terminal") + expect(gateRulePattern("Bash")).toBe("Bash") + expect(gateRulePattern("Write")).toBe("Write") + }) + + it("refuses platform ops so commit/destructive ops always gate", () => { + expect(gateRulePattern("commit_revision")).toBeNull() + expect(gateRulePattern("create_schedule")).toBeNull() + expect(gateRulePattern("remove_subscription")).toBeNull() + expect(gateRulePattern("test_run")).toBeNull() + }) + + it("refuses client tools and MCP tools (mcp__ rules are dropped from the runner plan)", () => { + expect(gateRulePattern("request_connection")).toBeNull() + expect(gateRulePattern("request_input")).toBeNull() + expect(gateRulePattern("mcp__linear__create_issue")).toBeNull() + expect(gateRulePattern("")).toBeNull() + }) +}) + +describe("findGrantableHarnessTool", () => { + it("classifies an arbitrary harness gate verbatim and reports not-yet-allowed", () => { + expect(findGrantableHarnessTool(wrapHarness(), "Terminal")).toEqual({ + pattern: "Terminal", + allowed: false, + }) + expect(findGrantableHarnessTool(wrapHarness(), "bash")).toEqual({ + pattern: "bash", + allowed: false, + }) + }) + + it("reports allowed when the pattern is in harness.permissions.allow", () => { + const params = wrapHarness({allow: ["Terminal"]}) + expect(findGrantableHarnessTool(params, "Terminal")).toEqual({ + pattern: "Terminal", + allowed: true, + }) + expect(readHarnessAllowList(params)).toEqual(["Terminal"]) + }) + + it("returns null for a platform op", () => { + expect(findGrantableHarnessTool(wrapHarness(), "commit_revision")).toBeNull() + }) +}) + +describe("withHarnessToolAllow", () => { + it("adds the pattern to harness.permissions.allow (creating the slice)", () => { + const next = withHarnessToolAllow(wrapHarness(), "Terminal", true) as { + agent: {harness: {permissions: {allow: string[]}}} + } + expect(next.agent.harness.permissions.allow).toEqual(["Terminal"]) + }) + + it("is idempotent — no duplicate when already present", () => { + const next = withHarnessToolAllow(wrapHarness({allow: ["bash"]}), "bash", true) as { + agent: {harness: {permissions: {allow: string[]}}} + } + expect(next.agent.harness.permissions.allow).toEqual(["bash"]) + }) + + it("removes the pattern when allowed is false, preserving other entries", () => { + const next = withHarnessToolAllow( + wrapHarness({allow: ["bash", "Terminal"]}), + "bash", + false, + ) as {agent: {harness: {permissions: {allow: string[]}}}} + expect(next.agent.harness.permissions.allow).toEqual(["Terminal"]) + }) + + it("preserves other permission keys (e.g. default_mode)", () => { + const next = withHarnessToolAllow(wrapHarness({default_mode: "default"}), "Bash", true) as { + agent: {harness: {permissions: Record}} + } + expect(next.agent.harness.permissions.default_mode).toBe("default") + expect(next.agent.harness.permissions.allow).toEqual(["Bash"]) + }) + + it("does not mutate the input parameters", () => { + const params = wrapHarness({allow: ["Terminal"]}) + const snapshot = JSON.stringify(params) + withHarnessToolAllow(params, "bash", true) + expect(JSON.stringify(params)).toBe(snapshot) + }) +}) diff --git a/web/packages/agenta-playground/src/index.ts b/web/packages/agenta-playground/src/index.ts index e6501fef93..23faac7879 100644 --- a/web/packages/agenta-playground/src/index.ts +++ b/web/packages/agenta-playground/src/index.ts @@ -79,7 +79,7 @@ export { } from "./state" export type {AgentRequest, AgentChannelMode, NegotiatingFetch} from "./state" // HITL resume predicate for `useChat`'s `sendAutomaticallyWhen` (approve AND deny resume). -export {agentShouldResumeAfterApproval} from "./state" +export {agentShouldResumeAfterApproval, type LiveAgentInteraction} from "./state" // Render-hint map for interaction kinds (sibling `data-render` parts → toolCallId lookup). export {buildRenderMap, renderKindFor, type RenderHintLike} from "./state" // Queued-message release gate for the agent chat composer (HITL-safe, one-by-one). diff --git a/web/packages/agenta-playground/src/state/execution/agentApprovalResume.ts b/web/packages/agenta-playground/src/state/execution/agentApprovalResume.ts index c097bd3e39..dd648a3830 100644 --- a/web/packages/agenta-playground/src/state/execution/agentApprovalResume.ts +++ b/web/packages/agenta-playground/src/state/execution/agentApprovalResume.ts @@ -24,11 +24,16 @@ */ interface ApprovalLike { + id?: string approved?: boolean } import {buildRenderMap, renderKindFor, type RenderHintLike} from "./renderMap" +export type LiveAgentInteraction = + | {kind: "approval"; id: string} + | {kind: "client_tool"; id: string} + interface ToolPartLike { type?: string state?: string @@ -119,16 +124,20 @@ const isSettledToolPart = (part: ToolPartLike): boolean => part.state === "approval-responded") /** - * Resume when the last assistant turn carries at least one freshly-resolved parked interaction (an - * approval response OR a browser-fulfilled client-tool result) and EVERY non-provider-executed tool - * part on it is settled. Both paths share one rule so a single `sendAutomaticallyWhen` covers - * approvals and client tools alike: + * Resume when the last assistant turn carries a freshly-resolved parked interaction. Approval + * responses dispatch per card; browser-fulfilled client tools retain the all-settled rule: * - Approval (approve OR deny): a denied tool part is `approval-responded`, so a deny-only turn * still resumes and the runner gets the denial round-trip (the deny dead-end fix). * - Client tool: a `request_connection` the widget settled (success, cancel, failure, abandon) * reads as a client-tool result, so the run resumes and the runner re-resolves on cold-replay. */ -export function agentShouldResumeAfterApproval({messages}: {messages: MessageLike[]}): boolean { +export function agentShouldResumeAfterApproval({ + messages, + liveInteraction, +}: { + messages: MessageLike[] + liveInteraction?: LiveAgentInteraction | null +}): boolean { const last = messages[messages.length - 1] if (!last || last.role !== "assistant") return false @@ -139,27 +148,47 @@ export function agentShouldResumeAfterApproval({messages}: {messages: MessageLik // Message-scoped render hints (sibling `data-render` parts) for client-tool detection. const renderMap = buildRenderMap(parts) - // Index of the LAST freshly-resolved parked interaction (an approval response or a - // browser-fulfilled client-tool result). Using the last one handles chained gates: a second - // approval later in the turn is what should drive the (next) resume. let lastResolvedIdx = -1 - for (let i = 0; i < parts.length; i++) { - if (isRespondedToolPart(parts[i]) || isClientToolResult(parts[i], renderMap)) - lastResolvedIdx = i + let lastResolvedIsApproval = false + if (liveInteraction === null) return false + if (liveInteraction) { + lastResolvedIsApproval = liveInteraction.kind === "approval" + lastResolvedIdx = parts.findIndex((part) => + liveInteraction.kind === "approval" + ? isRespondedToolPart(part) && part.approval?.id === liveInteraction.id + : isClientToolResult(part, renderMap) && part.toolCallId === liveInteraction.id, + ) + } else { + // Queue/orphan checks omit a marker and inspect the latest resolved interaction shape. + for (let i = 0; i < parts.length; i++) { + if (isRespondedToolPart(parts[i])) { + lastResolvedIdx = i + lastResolvedIsApproval = true + } else if (isClientToolResult(parts[i], renderMap)) { + lastResolvedIdx = i + lastResolvedIsApproval = false + } + } } if (lastResolvedIdx === -1) return false - // ALREADY RESUMED guard (the post-resolve loop). The cold-replay runner re-issues the approved - // tool under a FRESH id, so its execution output attaches to a NEW part and the original - // `approval-responded` part LINGERS in this same assistant message forever. Once the model has - // continued past the approval, a new step begins — a `step-start` part appears AFTER it. Without - // this guard the predicate keeps seeing the lingering `approval-responded` and auto-resends after - // every completion, re-running the whole turn endlessly (the loop the HITL fix exposed). - const resumedAlready = parts - .slice(lastResolvedIdx + 1) - .some((part) => part.type === "step-start") - if (resumedAlready) return false - - const allSettled = toolParts.every(isSettledToolPart) - return allSettled + if (!liveInteraction) { + // Restored tails need this guard; exact live markers are single-use and may target a part before `step-start`. + const resumedAlready = parts + .slice(lastResolvedIdx + 1) + .some((part) => part.type === "step-start") + if (resumedAlready) return false + } + + // The AI SDK re-evaluates after message updates and waits for an in-flight stream to finish, + // so an approval clicked during a resume dispatches when that stream finishes. + if (!liveInteraction) return toolParts.every(isSettledToolPart) + + if (lastResolvedIsApproval) { + const pendingClientTool = toolParts.some((part) => + isPendingClientToolInteraction(part, renderMap), + ) + return !pendingClientTool + } + return toolParts.every(isSettledToolPart) } diff --git a/web/packages/agenta-playground/src/state/execution/index.ts b/web/packages/agenta-playground/src/state/execution/index.ts index f6a0778ca5..8afdad87f6 100644 --- a/web/packages/agenta-playground/src/state/execution/index.ts +++ b/web/packages/agenta-playground/src/state/execution/index.ts @@ -363,7 +363,7 @@ export {agentChannelModeAtomFamily, type AgentChannelMode} from "./channelMode" // Transport negotiation: try stream, fall back to batch on 406, error gracefully otherwise. export {createNegotiatingFetch, type NegotiatingFetch} from "./agentNegotiation" // Agent-lane HITL resume predicate (approve AND deny both resume the conversation). -export {agentShouldResumeAfterApproval} from "./agentApprovalResume" +export {agentShouldResumeAfterApproval, type LiveAgentInteraction} from "./agentApprovalResume" // Render-hint map: sibling `data-render` parts → toolCallId lookup (interaction kinds). export {buildRenderMap, renderKindFor, type RenderHintLike} from "./renderMap" // Agent-lane queued-message release gate (never releases mid-HITL or pre-resume). diff --git a/web/packages/agenta-playground/src/state/index.ts b/web/packages/agenta-playground/src/state/index.ts index eee75406f1..d41a18f761 100644 --- a/web/packages/agenta-playground/src/state/index.ts +++ b/web/packages/agenta-playground/src/state/index.ts @@ -183,7 +183,7 @@ export { } from "./execution" export {agentChannelModeAtomFamily, type AgentChannelMode} from "./execution" export {createNegotiatingFetch, type NegotiatingFetch} from "./execution" -export {agentShouldResumeAfterApproval} from "./execution" +export {agentShouldResumeAfterApproval, type LiveAgentInteraction} from "./execution" export {buildRenderMap, renderKindFor, type RenderHintLike} from "./execution" export {canReleaseQueuedMessage, isHitlPending} from "./execution" export { diff --git a/web/packages/agenta-playground/tests/unit/agentApprovalResume.test.ts b/web/packages/agenta-playground/tests/unit/agentApprovalResume.test.ts index 8d1455eeaf..fd71915e3a 100644 --- a/web/packages/agenta-playground/tests/unit/agentApprovalResume.test.ts +++ b/web/packages/agenta-playground/tests/unit/agentApprovalResume.test.ts @@ -43,6 +43,30 @@ const assistantWithClientTool = (state: string, output?: unknown) => ({ ], }) +const warmResumedSecondApproval = () => ({ + id: "a1", + role: "assistant", + parts: [ + {type: "step-start"}, + { + type: "tool-bash", + toolCallId: "call_1", + state: "output-error", + input: {command: "command a"}, + errorText: "APPROVED_EXECUTION_RESULT_UNKNOWN: result was not observed", + approval: {id: "perm_1", approved: true}, + }, + { + type: "tool-bash", + toolCallId: "call_2", + state: "approval-responded", + input: {command: "command b"}, + approval: {id: "perm_2", approved: true}, + }, + {type: "step-start"}, + ], +}) + describe("agentShouldResumeAfterApproval", () => { it("RESUMES on a deny-only decision (the F-036 dead-end fix)", () => { const messages = [user("do it"), assistantWithTool("approval-responded", false)] @@ -54,6 +78,11 @@ describe("agentShouldResumeAfterApproval", () => { expect(agentShouldResumeAfterApproval({messages})).toBe(true) }) + it("does NOT resume a rebuilt answered conversation without a live interaction", () => { + const messages = [user("do it"), assistantWithTool("approval-responded", true)] + expect(agentShouldResumeAfterApproval({messages, liveInteraction: null})).toBe(false) + }) + it("does NOT resume while a gate is still pending (approval-requested)", () => { const messages = [user("do it"), assistantWithTool("approval-requested")] expect(agentShouldResumeAfterApproval({messages})).toBe(false) @@ -72,7 +101,7 @@ describe("agentShouldResumeAfterApproval", () => { expect(agentShouldResumeAfterApproval({messages})).toBe(false) }) - it("does NOT resume when a sibling tool on the turn is unsettled", () => { + it("resumes when one approval is answered and a sibling approval remains pending", () => { const messages = [ user("do two"), { @@ -97,7 +126,12 @@ describe("agentShouldResumeAfterApproval", () => { ], }, ] - expect(agentShouldResumeAfterApproval({messages})).toBe(false) + expect( + agentShouldResumeAfterApproval({ + messages, + liveInteraction: {kind: "approval", id: "perm_1"}, + }), + ).toBe(true) }) it("resumes when a responded gate sits alongside an already-completed tool", () => { @@ -128,6 +162,117 @@ describe("agentShouldResumeAfterApproval", () => { expect(agentShouldResumeAfterApproval({messages})).toBe(true) }) + it("RESUMES per card while another concurrent approval is pending", () => { + // One live answer dispatches immediately; the runner carries the untouched gate forward. + const messages = [ + user("do two"), + { + id: "a1", + role: "assistant", + parts: [ + {type: "step-start"}, + { + type: "tool-deleteFile", + toolCallId: "call_1", + state: "approval-responded", + input: {path: "/x"}, + approval: {id: "perm_1", approved: true}, + }, + { + type: "tool-deleteFile", + toolCallId: "call_2", + state: "approval-requested", + input: {path: "/y"}, + approval: {id: "perm_2"}, + }, + ], + }, + ] + expect( + agentShouldResumeAfterApproval({ + messages, + liveInteraction: {kind: "approval", id: "perm_1"}, + }), + ).toBe(true) + }) + + it("matches the clicked approval when a later client-tool result exists", () => { + const messages = [ + user("approve one while connection result is present"), + { + id: "a1", + role: "assistant", + parts: [ + {type: "step-start"}, + { + type: "tool-deleteFile", + toolCallId: "call_clicked", + state: "approval-responded", + input: {path: "/x"}, + approval: {id: "perm_clicked", approved: true}, + }, + { + type: "tool-writeFile", + toolCallId: "call_pending", + state: "approval-requested", + input: {path: "/y"}, + approval: {id: "perm_pending"}, + }, + { + type: "tool-request_connection", + toolCallId: "call_client", + state: "output-available", + input: {integration: "github"}, + output: {connected: true}, + }, + ], + }, + ] + + expect( + agentShouldResumeAfterApproval({ + messages, + liveInteraction: {kind: "approval", id: "perm_clicked"}, + }), + ).toBe(true) + expect( + agentShouldResumeAfterApproval({ + messages, + liveInteraction: {kind: "approval", id: "perm_missing"}, + }), + ).toBe(false) + }) + + it("RESUMES once BOTH concurrent approval cards are answered", () => { + // Same two-gate turn, now both answered (one approve, one deny). Every card is settled + // (`approval-responded`), so the run resumes and the runner gets both round-trips. + const messages = [ + user("do two"), + { + id: "a1", + role: "assistant", + parts: [ + {type: "step-start"}, + { + type: "tool-deleteFile", + toolCallId: "call_1", + state: "approval-responded", + input: {path: "/x"}, + approval: {id: "perm_1", approved: true}, + }, + { + type: "tool-deleteFile", + toolCallId: "call_2", + state: "approval-responded", + input: {path: "/y"}, + approval: {id: "perm_2", approved: false}, + }, + ], + }, + ] + expect(agentShouldResumeAfterApproval({messages})).toBe(true) + }) + it("does NOT resume when there are no messages", () => { expect(agentShouldResumeAfterApproval({messages: []})).toBe(false) }) @@ -163,6 +308,33 @@ describe("agentShouldResumeAfterApproval", () => { expect(agentShouldResumeAfterApproval({messages})).toBe(false) }) + it("keeps the all-settled rule when an approval sits beside a pending client tool", () => { + const messages = [ + user("connect then approve"), + { + id: "a1", + role: "assistant", + parts: [ + {type: "step-start"}, + { + type: "tool-request_connection", + toolCallId: "call_c", + state: "input-available", + input: {integration: "github"}, + }, + { + type: "tool-deleteFile", + toolCallId: "call_1", + state: "approval-responded", + input: {path: "/x"}, + approval: {id: "perm_1", approved: true}, + }, + ], + }, + ] + expect(agentShouldResumeAfterApproval({messages})).toBe(false) + }) + it("resumes when a fulfilled client tool sits alongside a responded approval", () => { const messages = [ user("do it then connect"), @@ -286,6 +458,23 @@ describe("agentShouldResumeAfterApproval", () => { expect(agentShouldResumeAfterApproval({messages})).toBe(false) }) + it("dispatches a live second-card answer before a warm-resume step tail", () => { + const messages = [user("run two commands"), warmResumedSecondApproval()] + + expect( + agentShouldResumeAfterApproval({ + messages, + liveInteraction: {kind: "approval", id: "perm_2"}, + }), + ).toBe(true) + }) + + it("keeps the same warm-resume step tail inert without a live marker", () => { + const messages = [user("run two commands"), warmResumedSecondApproval()] + + expect(agentShouldResumeAfterApproval({messages})).toBe(false) + }) + it("STILL resumes on a chained second approval later in the turn", () => { // Two gates in one turn: the first was approved-and-resumed (step-start follows it), then a // SECOND gate was just approved and is the tail — its approval still needs the resume. diff --git a/web/packages/agenta-shared/src/state/draftConfigChangeSignal.ts b/web/packages/agenta-shared/src/state/draftConfigChangeSignal.ts new file mode 100644 index 0000000000..d5e415dcf4 --- /dev/null +++ b/web/packages/agenta-shared/src/state/draftConfigChangeSignal.ts @@ -0,0 +1,31 @@ +import {atom} from "jotai" + +/** + * Raised when an interaction OUTSIDE the config pane mutates the DRAFT agent config + * (e.g. flipping "always allow" in the approval dock writes a per-tool permission). + * The draft/uncommitted counterpart of {@link agentSelfCommitSignalAtom}: that one marks + * a COMMITTED self-commit in agent teal; this one marks an UNCOMMITTED, user-initiated + * change in draft blue, so the config sections it touched can pulse for attention even + * when the user's eyes are on the chat. + * + * Config-scoped by design — files (time-based recency) and triggers (persisted, not draft) + * keep their own "changed" engines; only the shared visual language is reused. + * Cleared by the next draft change or by dismissal. + */ +export interface DraftConfigChangeSignal { + /** The draft revision whose config changed. */ + revisionId: string + /** Config section keys to light up, e.g. ["tools"] or ["model-harness"]. */ + sectionKeys: string[] + /** Where the change came from — extensible provenance for future callers. */ + origin: "approval-dock" + /** Short human summary for the tooltip, e.g. "Always allow search_web". */ + summary?: string + /** Friendly label for the config-pane banner, e.g. "Send email". */ + label?: string + /** The tool the change targeted, so the banner's Undo can revert it. */ + toolName?: string + at: number +} + +export const draftConfigChangeSignalAtom = atom(null) diff --git a/web/packages/agenta-shared/src/state/index.ts b/web/packages/agenta-shared/src/state/index.ts index d65e0ef298..68dd12dfd3 100644 --- a/web/packages/agenta-shared/src/state/index.ts +++ b/web/packages/agenta-shared/src/state/index.ts @@ -11,6 +11,10 @@ export {openAgentConfigSectionAtom} from "./openConfigSection" export type {AgentConfigSection} from "./openConfigSection" export {agentSelfCommitSignalAtom} from "./agentCommitSignal" export type {AgentSelfCommitSignal} from "./agentCommitSignal" +export {draftConfigChangeSignalAtom} from "./draftConfigChangeSignal" +export type {DraftConfigChangeSignal} from "./draftConfigChangeSignal" +export {providerKeyAddedSignalAtom} from "./providerKeyAddedSignal" +export type {ProviderKeyAddedSignal} from "./providerKeyAddedSignal" export {atomWithRefresh} from "jotai/utils" export { atomWithCompare, diff --git a/web/packages/agenta-shared/src/state/providerKeyAddedSignal.ts b/web/packages/agenta-shared/src/state/providerKeyAddedSignal.ts new file mode 100644 index 0000000000..48fc0b0139 --- /dev/null +++ b/web/packages/agenta-shared/src/state/providerKeyAddedSignal.ts @@ -0,0 +1,18 @@ +import {atom} from "jotai" + +/** + * Raised when a provider API key is saved from the config pane's "Connect key" flow (the inline + * provider-credentials pane or its drawer). Drives the success banner pinned to the bottom of the + * config pane — the same pattern as {@link agentSelfCommitSignalAtom} / + * {@link draftConfigChangeSignalAtom}, in success green: it confirms the agent can now run without + * pulling focus away with a floating toast. Cleared by dismissal or after an auto-dismiss timeout. + */ +export interface ProviderKeyAddedSignal { + /** The displayed revision the key was connected for. */ + revisionId: string + /** Friendly provider name for the banner, e.g. "OpenAI". */ + provider?: string + at: number +} + +export const providerKeyAddedSignalAtom = atom(null) diff --git a/web/packages/agenta-ui/package.json b/web/packages/agenta-ui/package.json index 1746b64c73..68bc98078a 100644 --- a/web/packages/agenta-ui/package.json +++ b/web/packages/agenta-ui/package.json @@ -97,6 +97,7 @@ "lexical": "^0.46.0", "lucide-react": "^0.479.0", "marked": "^17.0.4", + "motion": "^12.0.0", "prismjs": "^1.30.0", "react-resizable": "^3.0.5", "uuid": "^11.1.1" diff --git a/web/packages/agenta-ui/src/components/HeightCollapse.tsx b/web/packages/agenta-ui/src/components/HeightCollapse.tsx index 5a91812c6f..e9f74ff2f8 100644 --- a/web/packages/agenta-ui/src/components/HeightCollapse.tsx +++ b/web/packages/agenta-ui/src/components/HeightCollapse.tsx @@ -10,13 +10,34 @@ export interface HeightCollapseProps { durationMs?: number animate?: boolean collapsedHeight?: number + /** + * Also fade opacity 0↔1 with the height. Use for docked chrome/notices where content appearing + * sharply as the box unfolds looks abrupt; omit to keep the plain height-only collapse used by + * the tool gutter, accordion sections, etc. Default false. + */ + fade?: boolean + /** + * Also translate the content on the Y axis: it sits `slideY`px below its resting place while + * closed and eases to 0 on open (and back on close) — a subtle slide for bottom-docked notices. + * Pair with `fade` so the collapsing frames read cleanly. Default 0 (no translate). + */ + slideY?: number + /** + * Apply the `inert` attribute while FULLY closed, dropping the hidden subtree from tab order + + * a11y (e.g. an approval dock's buttons must not be reachable when collapsed). Opt-in so a + * `collapsedHeight > 0` peek keeps its still-visible content interactive. Default false. + */ + inert?: boolean } /** * HeightCollapse * - * CSS-native collapse that transitions `height` between pixel values and `auto` - * using `interpolate-size: allow-keywords`. + * CSS-native collapse that transitions `height` between pixel values and `auto` using + * `interpolate-size: allow-keywords`. Plain CSS transitions (NOT `motion-safe`-gated), so it + * animates regardless of the OS reduced-motion setting. The single collapse primitive for chrome + * that enters/leaves a column — accordion sections, the tool gutter, and (via `fade`/`slideY`) the + * composer dock, queued messages, and the config-pane notices — so everything moves the same way. */ export function HeightCollapse({ open, @@ -26,24 +47,53 @@ export function HeightCollapse({ durationMs = 300, animate = true, collapsedHeight = 0, + fade = false, + slideY = 0, + inert = false, }: HeightCollapseProps) { const collapsedHeightPx = useMemo(() => `${Math.max(0, collapsedHeight)}px`, [collapsedHeight]) - const style = useMemo( + const easing = "cubic-bezier(0.4, 0, 0.2, 1)" + + const outerStyle = useMemo( () => ({ height: open ? "auto" : collapsedHeightPx, + opacity: fade ? (open ? 1 : 0) : undefined, interpolateSize: "allow-keywords", - transitionProperty: animate ? "height" : "none", + transitionProperty: animate ? (fade ? "height, opacity" : "height") : "none", transitionDuration: animate ? `${durationMs}ms` : "0ms", - transitionTimingFunction: animate ? "cubic-bezier(0.4, 0, 0.2, 1)" : "linear", + transitionTimingFunction: animate ? easing : "linear", }) as React.CSSProperties, - [open, collapsedHeightPx, animate, durationMs], + [open, collapsedHeightPx, animate, fade, durationMs], ) + const innerStyle = useMemo( + () => + slideY + ? { + transform: open ? "translateY(0)" : `translateY(${slideY}px)`, + transitionProperty: animate ? "transform" : "none", + transitionDuration: animate ? `${durationMs}ms` : "0ms", + transitionTimingFunction: animate ? easing : "linear", + } + : undefined, + [slideY, open, animate, durationMs], + ) + + // `inert` only while fully collapsed — a peek (collapsedHeight > 0) stays interactive. + const isInert = inert && !open && collapsedHeight === 0 + return ( -
-
{children}
+
+
+ {children} +
) } diff --git a/web/packages/agenta-ui/src/components/presentational/index.ts b/web/packages/agenta-ui/src/components/presentational/index.ts index 61ed6c848f..b2e7767d9b 100644 --- a/web/packages/agenta-ui/src/components/presentational/index.ts +++ b/web/packages/agenta-ui/src/components/presentational/index.ts @@ -63,6 +63,8 @@ export { SectionSkeleton, ConfigAccordionSection, sectionIndicatorColor, + useAccordionSectionOpen, + useRecentFlag, type SectionCardProps, type SectionHeaderRowProps, type SectionLabelProps, diff --git a/web/packages/agenta-ui/src/components/presentational/section/ConfigAccordionSection.tsx b/web/packages/agenta-ui/src/components/presentational/section/ConfigAccordionSection.tsx index fba6392761..d36c4f35fc 100644 --- a/web/packages/agenta-ui/src/components/presentational/section/ConfigAccordionSection.tsx +++ b/web/packages/agenta-ui/src/components/presentational/section/ConfigAccordionSection.tsx @@ -26,16 +26,39 @@ * * ``` */ -import {type ReactNode, useCallback, useEffect, useRef, useState} from "react" +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react" import {CaretDown, CaretRight, Lock} from "@phosphor-icons/react" import {Tooltip, Typography} from "antd" +import {motion} from "motion/react" import {cn} from "../../../utils/styles" import {HeightCollapse} from "../../HeightCollapse" const {Text} = Typography +/** + * Whether the enclosing accordion section is currently expanded. Because the body stays MOUNTED while + * collapsed (height-0 via {@link HeightCollapse}), a child can't rely on mount to know it just became + * visible — an `autoFocus` fires while hidden and never again. Read this instead to (re)act on open, + * e.g. focus a field when its section unfolds. Defaults to `true` outside a section, so a child used + * elsewhere behaves as always-open. + */ +const SectionOpenContext = createContext(true) + +/** Read whether the enclosing {@link ConfigAccordionSection} is expanded. See {@link SectionOpenContext}. */ +export function useAccordionSectionOpen(): boolean { + return useContext(SectionOpenContext) +} + export type SectionIndicatorTone = "draft" | "invalid" | "incomplete" | "agent" /** @@ -105,9 +128,11 @@ export interface ConfigAccordionSectionProps { * Change/validation indicator for the leading icon: tints the icon, adds a status dot, * and (with `tooltip`) explains it on hover. Takes precedence over `status`. Used by the * agent config panel to flag sections with unsaved edits (`"draft"`), a blocking problem - * (`"invalid"`), or an optional gap (`"incomplete"`). + * (`"invalid"`), or an optional gap (`"incomplete"`). `pulse` plays a one-shot attention + * ring behind the dot (e.g. a change made from another pane); the caller owns how long it + * stays true (see `useRecentFlag`). */ - indicator?: {tone: SectionIndicatorTone; tooltip?: ReactNode} + indicator?: {tone: SectionIndicatorTone; tooltip?: ReactNode; pulse?: boolean} /** Only show `summary` while the section is collapsed. @default false (always). */ summaryCollapsedOnly?: boolean /** @@ -117,6 +142,17 @@ export interface ConfigAccordionSectionProps { titleBadge?: ReactNode /** Additional CSS class for the section wrapper. */ className?: string + /** + * Opt in to the expanded-header "band" — a faint fill + bottom divider while the section is open, + * so the header reads as a header rather than blending into its content. + * + * The value is the BLEED class the host needs to pull the fill out to its own edges while the + * header text stays aligned with the content below, e.g. `"-mx-4 px-4"` inside a `px-4` container. + * It has to come from the host because this primitive is used in containers with different + * padding (config panel, drawers, nested sections) and a hardcoded bleed would overflow or + * under-fill in the others. Omit for no band (the default — every existing usage is unchanged). + */ + headerBand?: string /** * Fade + subtle rise the section in on mount (opacity/transform only — no layout impact). Off by * default so existing usages are unchanged; the agent config panel opts in (staggered via @@ -132,6 +168,13 @@ export interface ConfigAccordionSectionProps { * collapsible, `defaultOpen` sections only — a no-op everywhere else. */ animateInitialOpen?: boolean + /** + * Override the body wrapper classes (the padded flex column around `children`). Defaults to + * `"flex flex-col gap-3 pb-4 pt-3"`. Pass a padding-free value when the body owns its own spacing + * — e.g. content that must collapse to zero height with no residual padding for an exit + * transition. + */ + bodyClassName?: string /** Section body. */ children?: ReactNode } @@ -158,9 +201,11 @@ export function ConfigAccordionSection({ summaryCollapsedOnly = false, titleBadge, className, + headerBand, revealOnMount = false, revealDelayMs = 0, animateInitialOpen = false, + bodyClassName = "flex flex-col gap-3 pb-4 pt-3", children, }: ConfigAccordionSectionProps) { // Height (0→auto via the grid `0fr`→`1fr` trick) + opacity reveal on mount (opt-in). `revealed` @@ -209,6 +254,9 @@ export function ConfigAccordionSection({ !opensDrawer && !locked && (collapsible ? (isControlled ? open : internalOpen) : true) const canToggle = !opensDrawer && collapsible && !locked const headerActs = canToggle || opensDrawer + // Whether the expanded-header band (fill + bottom divider) is currently shown. `isOpen` is already + // false when the section opens a drawer or is locked. + const banded = isOpen && !!headerBand const activate = useCallback(() => { if (opensDrawer) { @@ -225,7 +273,32 @@ export function ConfigAccordionSection({
@@ -246,6 +319,14 @@ export function ConfigAccordionSection({ "flex items-center justify-between gap-2 py-3 select-none", locked && "cursor-not-allowed opacity-60", headerActs && "cursor-pointer", + // Header fill: bled to the section edges and layered over the section's body fill so + // the header reads a touch stronger than the content beneath it. Fades with the band. + headerBand && + cn( + headerBand, + "transition-colors duration-300 ease-out", + banded ? "bg-[var(--ag-colorFillQuaternary)]" : "bg-transparent", + ), )} >
@@ -256,6 +337,23 @@ export function ConfigAccordionSection({ style={{color: iconColor}} > {icon} + {/* Attention ripple: expands + fades out from behind the dot, + repeating while the caller holds `pulse` true (e.g. a change + from another pane). Uses `motion` so it animates reliably. */} + {indicator?.pulse ? ( + + ) : null} {/* Always mounted: state changes play as scale/opacity/color transitions instead of the dot popping in and out. */} ) : null} - - {title} - + {/* Title. The base text stays fully opaque/crisp; while `pulse` holds, a blue + DUPLICATE laid exactly on top is revealed through a moving mask, so a glint + sweeps across the letters (in cadence with the dot ripple) without ever + fading the real text. Sweep = the `config-shimmer` CSS keyframe (motion is + unreliable at animating mask/background position). */} + + + {title} + + {indicator?.pulse ? ( + + {title} + + ) : null} + {titleBadge ? {titleBadge} : null}
@@ -320,7 +447,13 @@ export function ConfigAccordionSection({ {opensDrawer ? null : ( -
{children}
+ {/* Top padding gives the body room below the header band (which carries the + fill + bottom divider), so it reads as content, not a header continuation. */} +
+ + {children} + +
)}
diff --git a/web/packages/agenta-ui/src/components/presentational/section/index.tsx b/web/packages/agenta-ui/src/components/presentational/section/index.tsx index af361561a3..3e946e228f 100644 --- a/web/packages/agenta-ui/src/components/presentational/section/index.tsx +++ b/web/packages/agenta-ui/src/components/presentational/section/index.tsx @@ -174,4 +174,6 @@ export { type ConfigAccordionSectionProps, sectionIndicatorColor, type SectionIndicatorTone, + useAccordionSectionOpen, } from "./ConfigAccordionSection" +export {useRecentFlag} from "./useRecentFlag" diff --git a/web/packages/agenta-ui/src/components/presentational/section/useRecentFlag.ts b/web/packages/agenta-ui/src/components/presentational/section/useRecentFlag.ts new file mode 100644 index 0000000000..f803f33e8c --- /dev/null +++ b/web/packages/agenta-ui/src/components/presentational/section/useRecentFlag.ts @@ -0,0 +1,26 @@ +import {useEffect, useState} from "react" + +/** + * True for `windowMs` after `at`, then false. A caller-owned "just happened" clock for + * transient attention cues (e.g. a pulsing section indicator). Re-arms whenever `at` + * changes, and self-stops with a single timer so idle surfaces don't re-render. Mirrors + * the time-based recency the Drives files use, in a package-safe form. + */ +export function useRecentFlag(at: number | null | undefined, windowMs = 8000): boolean { + const [recent, setRecent] = useState(() => at != null && Date.now() - at < windowMs) + useEffect(() => { + if (at == null) { + setRecent(false) + return + } + const elapsed = Date.now() - at + if (elapsed >= windowMs) { + setRecent(false) + return + } + setRecent(true) + const id = window.setTimeout(() => setRecent(false), windowMs - elapsed) + return () => window.clearTimeout(id) + }, [at, windowMs]) + return recent +} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index b32be1bb0b..c7ddb14a51 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -1401,6 +1401,9 @@ importers: marked: specifier: ^17.0.4 version: 17.0.6 + motion: + specifier: ^12.0.0 + version: 12.38.0(@emotion/is-prop-valid@0.7.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) prismjs: specifier: ^1.30.0 version: 1.30.0