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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 97 additions & 6 deletions src/xagent/web/api/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
parse_user_sandbox_key,
)
from ..schemas.chat import TaskCreateRequest, TaskCreateResponse
from ..schemas.connector_runtime import ConnectorRuntimeRequirementsModel
from ..services.agent_access import list_accessible_published_agents
from ..services.agent_team_scope import (
get_agent_team_scope,
Expand All @@ -89,7 +90,8 @@
)
from ..services.connector_runtime import (
bind_connector_runtime_selection_snapshot,
prepare_connector_runtime_selection_snapshot,
build_task_runtime_requirements,
resolve_agent_runtime_requirements,
)
from ..services.db_runtime import (
drain_async_task_cancellation_safe,
Expand Down Expand Up @@ -4488,10 +4490,12 @@ def _get_default_internal_model_ids() -> Dict[str, Optional[str]]:
agent_id=request.agent_id, # Set agent_id if provided
is_visible=False if request.is_preview else request.is_visible,
)
selected_refs = prepare_connector_runtime_selection_snapshot(
db=db,
agent=selected_agent,
connector_user_id=int(user.id),
selected_refs, connector_runtime_requirements = (
resolve_agent_runtime_requirements(
db=db,
agent=selected_agent,
connector_user_id=int(user.id),
)
)
bind_connector_runtime_selection_snapshot(
task=task, selected_refs=selected_refs
Expand Down Expand Up @@ -4668,6 +4672,7 @@ def _get_default_internal_model_ids() -> Dict[str, Optional[str]]:
runtime_extensions=runtime_extensions,
runtime_extensions_status=runtime_extensions_status,
runtime_extensions_omitted=runtime_extensions_omitted,
connector_runtime_requirements=connector_runtime_requirements,
)

except HTTPException:
Expand All @@ -4694,7 +4699,7 @@ def _get_default_internal_model_ids() -> Dict[str, Optional[str]]:
#
# The asymmetry with the ConnectorRuntimeError arm above is real
# and deliberate: that arm has a live producer inside this endpoint
# (``prepare_connector_runtime_selection_snapshot``), this one has
# (``resolve_agent_runtime_requirements``), this one has
# none. It is kept because what the two arms share is the
# failure-path contract, not the producer: both errors carry their
# own status and a caller-safe message, and the blanket handler
Expand Down Expand Up @@ -5288,6 +5293,92 @@ async def get_task_runtime_extensions(
}


@chat_router.get(
"/agent/{agent_id}/connector-runtime-requirements",
response_model=ConnectorRuntimeRequirementsModel,
)
async def get_agent_connector_runtime_requirements(
agent_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> ConnectorRuntimeRequirementsModel:
"""Report the runtime inputs a prospective task would need, before one
exists.

Reuses the same predicate ``POST /task/create`` applies to an agent id
(``_load_agent_for_task_create``) rather than a second authorization
path for the same resource, so a caller who could create a task with
this agent sees exactly the same "not found" boundary here that they
would hit on that call. Lives on the chat router, next to the
task-keyed sibling below and the existing task-keyed
``/task/{task_id}/runtime-extensions``, rather than under
``/api/agents`` -- this endpoint has no consumer outside chat.

There is no task yet, so every reported input is unsatisfied and the
connector team scope is whatever ``resolve_agent_selected_connectors``
derives from the agent's own team, never a value this endpoint passes
in itself.
"""

agent = _load_agent_for_task_create(db, user, agent_id)
if agent is None:
raise HTTPException(status_code=404, detail="Agent not found or access denied")
try:
_refs, requirements = resolve_agent_runtime_requirements(
db=db, agent=agent, connector_user_id=int(user.id)
)
except ConnectorRuntimeError as exc:
raise HTTPException(
status_code=exc.status_code, detail=exc.safe_message
) from exc
return requirements


@chat_router.get(
"/task/{task_id}/connector-runtime-requirements",
response_model=ConnectorRuntimeRequirementsModel,
)
async def get_task_connector_runtime_requirements(
task_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> ConnectorRuntimeRequirementsModel:
"""Report which of a task's declared connector runtime inputs already
have a value.

Access is plain task ownership -- ``Task.user_id == current_user.id`` in
the same query that loads the task, unlike
``/task/{task_id}/runtime-extensions`` above, which additionally lets an
admin read any task; this endpoint does not extend that exception.
A task that does not exist or is not the caller's own is a uniform 404.

Pure read: never writes, and never asserts that a required value is
present -- that assertion belongs to the per-turn gate that runs later,
not to this report.
"""

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 report is built from 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. 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. A report that says what a turn will need can afford neither
# the over-report nor the under-report.
workforce_runtime = resolve_workforce_task_runtime(db, task)
agent = _load_agent_for_task_runtime(db, task, workforce_runtime)
try:
return build_task_runtime_requirements(db=db, task=task, agent=agent)
except ConnectorRuntimeError as exc:
raise HTTPException(
status_code=exc.status_code, detail=exc.safe_message
) from exc


@chat_router.delete("/task/{task_id}")
async def delete_task(
task_id: int,
Expand Down
11 changes: 11 additions & 0 deletions src/xagent/web/api/public_chat_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from ..models.user_channel import UserChannel
from ..models.workforce import Workforce, WorkforceRun
from ..schemas.chat import TaskCreateRequest, TaskCreateResponse
from ..schemas.connector_runtime import ConnectorRuntimeRequirementsModel
from ..services.client_error_messages import ClientErrorCode, client_error_message
from ..services.connector_runtime import (
bind_connector_runtime_selection_snapshot,
Expand Down Expand Up @@ -76,6 +77,12 @@
# the worst single-request abuse. Broader quota + orphan GC tracked in #973.
MAX_TASKLESS_SHARE_UPLOAD_FILES = 10

# Every public create path in this module answers this field the same way:
# it never resolves connector-runtime requirements for its caller, because
# the widget/share guest must not see connector key names. Kept as one
# named constant so the four call sites cannot drift apart.
_NO_CONNECTOR_RUNTIME_REQUIREMENTS: ConnectorRuntimeRequirementsModel | None = None


class PublicChatAuthResponse(BaseModel):
access_token: str
Expand Down Expand Up @@ -1036,6 +1043,7 @@ async def _create_workforce_widget_chat_task(
else None,
channel_id=task.channel_id,
channel_name=task.channel_name,
connector_runtime_requirements=_NO_CONNECTOR_RUNTIME_REQUIREMENTS,
)


Expand Down Expand Up @@ -1140,6 +1148,7 @@ async def create_public_chat_task(
else None,
channel_id=task.channel_id,
channel_name=task.channel_name,
connector_runtime_requirements=_NO_CONNECTOR_RUNTIME_REQUIREMENTS,
)


Expand Down Expand Up @@ -1209,6 +1218,7 @@ async def _create_workforce_share_chat_task(
else None,
channel_id=task.channel_id,
channel_name=task.channel_name,
connector_runtime_requirements=_NO_CONNECTOR_RUNTIME_REQUIREMENTS,
)


Expand Down Expand Up @@ -1297,6 +1307,7 @@ async def create_share_chat_task(
else None,
channel_id=task.channel_id,
channel_name=task.channel_name,
connector_runtime_requirements=_NO_CONNECTOR_RUNTIME_REQUIREMENTS,
)


Expand Down
15 changes: 15 additions & 0 deletions src/xagent/web/schemas/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from pydantic import BaseModel, Field, model_validator

from ...core.task_runtime import MAX_TASK_RUNTIME_EXTENSIONS
from .connector_runtime import ConnectorRuntimeRequirementsModel

# Only ever read here (TaskCreateRequest.seed_interactions is passed straight
# through to create_task_with_message as-is - see api/chat.py), unlike
Expand Down Expand Up @@ -194,6 +195,20 @@ class TaskCreateResponse(BaseModel):
"the status is `truncated`. Empty otherwise."
),
)
connector_runtime_requirements: ConnectorRuntimeRequirementsModel | None = Field(
...,
description=(
"Which runtime inputs this task's connectors still need, and "
"which of them already have a value. Always present in the "
"response body -- a client must not treat its absence as "
"meaning anything. Never includes a stored value itself, only "
"whether one exists. Present with a report on the web chat "
"create path (`POST /api/chat/task/create`); `null` on the "
"public chat and share-link create paths, meaning the "
"requirements were not evaluated there -- those visitors never "
"receive connector key names."
),
)


class ExecutionStatus(BaseModel):
Expand Down
93 changes: 93 additions & 0 deletions src/xagent/web/schemas/connector_runtime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""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
``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.
"""

from __future__ import annotations

from pydantic import BaseModel


class ConnectorRuntimeRefModel(BaseModel):
"""Wire identity of a connector, as returned in a requirements report."""

connector_type: str
connector_id: int


class ConnectorRuntimeInputModel(BaseModel):
"""One declared runtime input and whether it is currently satisfied.

``key`` is the raw key name a connector owner wrote when declaring the
input -- there is no human-readable label anywhere in the declaration.
``type`` is already normalized server-side to ``"string"`` or
``"object"``; a client must not normalize it again or expect any other
value. ``expired`` is a constant ``False`` in every section at this
phase; a later phase that adds a real secret store gives it a real
value without changing its meaning. ``satisfied`` is likewise a
constant ``False`` for the ``secrets`` and ``auth_selector`` sections,
because no secret store exists yet to hold such a value, and it is the
``section`` field that tells the two kinds of ``False`` apart: in the
``context`` section ``False`` means the value has not been supplied yet
and can be, while in ``secrets`` and ``auth_selector`` it means no
value can be supplied at this phase at all. One ``context`` key is
also always ``False``: a key whose name the per-turn gate rejects as
malformed, which is reported so a required key of that kind cannot
let the report read as met.
"""

section: str
key: str
type: str
required: bool
satisfied: bool
expired: bool = False


class ConnectorRuntimeConnectorModel(BaseModel):
"""One connector's declared runtime inputs.

``name`` is the only piece of connector identity beyond the ref that is
ever included -- never the connector's URL, headers, environment, or
authentication configuration.
"""

connector_ref: ConnectorRuntimeRefModel
name: str
inputs: list[ConnectorRuntimeInputModel]


class ConnectorRuntimeRequirementsModel(BaseModel):
"""A requirements report. Every field always appears.

``connectors`` is empty, never omitted, when nothing is selected or
declares a runtime input. ``secrets_expires_at`` is a constant ``null``
in this phase; a later phase gives it a real value without changing
its meaning or making it optional.

``satisfied`` answers a different question on each endpoint that
returns this model, and a client must read it against the endpoint it
called. The agent-keyed report has no task, so no value can be stored
against it: there ``satisfied`` answers "a task created from this agent
right now would need no further input", i.e. nothing required is
declared at all. The task-keyed report and the task-create response
answer "every required input of this task already has a value". The
per-input ``satisfied`` follows the same split: always ``False`` on the
agent-keyed report, and a real per-key answer on the other two.
"""

satisfied: bool
secrets_expires_at: str | None
connectors: list[ConnectorRuntimeConnectorModel]
Loading