Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 103 additions & 1 deletion src/xagent/web/api/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from typing import Any, Callable, Dict, List, Mapping, Optional, TypeVar, Union, cast

from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from sqlalchemy import func, or_, update
from sqlalchemy.orm import Session

Expand Down Expand Up @@ -75,7 +76,10 @@
parse_user_sandbox_key,
)
from ..schemas.chat import TaskCreateRequest, TaskCreateResponse
from ..schemas.connector_runtime import ConnectorRuntimeRequirementsModel
from ..schemas.connector_runtime import (
ConnectorRuntimeRequirementsModel,
ConnectorRuntimeValuesRequest,
)
from ..services.agent_access import list_accessible_published_agents
from ..services.agent_team_scope import (
get_agent_team_scope,
Expand All @@ -90,6 +94,7 @@
)
from ..services.client_error_messages import ClientErrorCode, client_error_message
from ..services.connector_runtime import (
apply_task_connector_runtime_context_values,
bind_connector_runtime_selection_snapshot,
build_task_runtime_requirements,
resolve_agent_runtime_requirements,
Expand Down Expand Up @@ -5301,6 +5306,32 @@ async def get_task_runtime_extensions(
}


def _connector_runtime_error_response(exc: ConnectorRuntimeError) -> JSONResponse:
"""Render a connector-runtime failure in this endpoint's error envelope.

Mirrors the ``{"error": {"code", "message", "details"}}`` shape
``_raise_v1_connector_runtime_error`` uses for the /v1 surface
(``api/v1/tasks.py``) -- only the envelope is shared, not ``V1ErrorCode``,
which is a separate SDK-facing contract this endpoint does not
participate in. The status code always comes from ``exc.status_code``,
never recomputed from ``exc.code`` via ``_status_for_code``: that helper
cannot produce 503, and both this endpoint's own conditional-update
failure and a team-scope resolution failure construct their
``ConnectorRuntimeError`` with an explicit ``status_code=503``.
"""

return JSONResponse(
status_code=exc.status_code,
content={
"error": {
"code": exc.code,
"message": exc.safe_message,
"details": exc.to_public_error()["details"],
}
},
)


@chat_router.get(
"/agent/{agent_id}/connector-runtime-requirements",
response_model=ConnectorRuntimeRequirementsModel,
Expand Down Expand Up @@ -5387,6 +5418,77 @@ async def get_task_connector_runtime_requirements(
) from exc


@chat_router.post(
"/task/{task_id}/connector-runtime-values",
response_model=ConnectorRuntimeRequirementsModel,
)
def post_task_connector_runtime_values(
task_id: int,
request: ConnectorRuntimeValuesRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> ConnectorRuntimeRequirementsModel | JSONResponse:
"""Accept ``context`` values for a task's connectors, merged key by key.

A key not yet stored is written; one already stored with the identical
value is a no-op; one already stored with a different value fails the
whole request with no partial write (``runtime_context_immutable``,
409). There is no override switch: a stored value is never replaced.
``secrets``/``auth_selector`` are out of scope this phase and rejected
at the request-shape level (``extra="forbid"``), not accepted and
ignored.

Access is the same plain task ownership the task-keyed read endpoint
applies -- ``Task.user_id == current_user.id`` in the query that loads
the task, with no admin exception -- so a task that does not exist and
one that is not the caller's own answer the same 404.

On success, the response is the same requirements report the read
endpoints return, reflecting exactly what was just written -- not the
request's own echo, and not whatever the read endpoints would have
said before this call. A 200 here means only that the submitted keys
were merged in; it says nothing about whether every required input is
now present, which is what the response's own ``satisfied`` fields
answer.

Every failure -- validation, a stored-value conflict, or a concurrent
write racing this one to the same row -- is rendered through
``_connector_runtime_error_response`` rather than the plain-``detail``
``HTTPException`` the two read endpoints above use, because a caller
needs the structured ``code``/``details.reason`` to decide how to
recover (retry, refresh and drop already-satisfied keys, or give up),
not just a status code.
"""

task = db.query(Task).filter(Task.id == task_id, Task.user_id == user.id).first()
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
# The connector scope this endpoint writes into, and reports on, must
# be the scope a turn would actually run under, so the agent is
# resolved by the same two calls the per-turn tool build makes for
# this task, in the same order -- the identical pair the task-keyed
# read endpoint above uses, so neither endpoint can answer with a
# connector set the other would not. Reading the agent row directly
# would key team-shared connectors on the raw row's team even where
# the runtime resolves the agent to None, and resolving with no
# workforce runtime would drop the team of a workforce manager agent
# whose run the runtime does find. Here the scope decides not only
# what the response lists but which connectors the caller may write
# to at all, so neither the over-report nor the under-report is
# acceptable.
workforce_runtime = resolve_workforce_task_runtime(db, task)
agent = _load_agent_for_task_runtime(db, task, workforce_runtime)
try:
requirements = apply_task_connector_runtime_context_values(
db=db, task=task, agent=agent, payload_items=request.items
)
except ConnectorRuntimeError as exc:
db.rollback()
return _connector_runtime_error_response(exc)
db.commit()
return requirements


@chat_router.delete("/task/{task_id}")
async def delete_task(
task_id: int,
Expand Down
55 changes: 41 additions & 14 deletions src/xagent/web/schemas/connector_runtime.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,25 @@
"""Connector runtime requirements: the shared response shape both read
endpoints return.

The agent-keyed and task-keyed read endpoints both return this same
requirements report -- which runtime inputs a task's (or a prospective
task's) connectors declare, and whether each one already has a value --
never a stored value itself, and never a connector's transport or
authentication configuration -- the same shape from both, with one field,
``satisfied``, answering a different question on each; see
"""Connector runtime requirements: shared response shape and the values
request body.

Four producers share the response shape: the agent-keyed and task-keyed
read endpoints, the task-create response, and the values endpoint's 200
response. All four describe a requirements report -- which runtime inputs
a task's (or a prospective task's) connectors declare, and whether each
one already has a value -- never a stored value itself, and never a
connector's transport or authentication configuration. One field,
``satisfied``, answers a different question depending on the producer; see
``ConnectorRuntimeRequirementsModel``.

Placed in its own module rather than ``schemas/chat.py`` because a values-
submission endpoint lands on top of it shortly and will share this same
response shape as its own 200 body; putting it here now avoids a later
move that would touch every existing importer.
Placed in its own module rather than ``schemas/chat.py`` or ``schemas/v1.py``
because it has more than one audience: folding it into either of those
modules would couple that module's own audience to the others.
"""

from __future__ import annotations

from typing import Literal

from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict


class ConnectorRuntimeRefModel(BaseModel):
Expand Down Expand Up @@ -107,3 +107,30 @@ class ConnectorRuntimeRequirementsModel(BaseModel):
satisfied: bool
secrets_expires_at: str | None
connectors: list[ConnectorRuntimeConnectorModel]


class ConnectorRuntimeValueItem(BaseModel):
"""One connector's caller-supplied context values.

``secrets`` and ``auth_selector`` are deliberately absent from this
phase's request shape: ``extra="forbid"`` turns either one into a 422
rather than silently accepting and discarding it.
"""

model_config = ConfigDict(extra="forbid")

connector_ref: ConnectorRuntimeRefModel
context: dict[str, object] | None = None


class ConnectorRuntimeValuesRequest(BaseModel):
"""Body of ``POST /api/chat/task/{task_id}/connector-runtime-values``.

No override switch of any kind belongs here: a stored value is never
replaced, and ``extra="forbid"`` turns an attempt to add one (for
example ``if_absent`` or ``force``) into a 422.
"""

model_config = ConfigDict(extra="forbid")

items: list[ConnectorRuntimeValueItem]
Loading