mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-15 07:41:20 +00:00
* fix(gateway): read the run and authorization config off the event loop get_app_config() stats and hashes config.yaml and extensions_config.json on every call and reparses them after an edit. Run admission read it inline on the loop two to four times per start (get_run_context, build_run_config, the model allowlist, the approval switch, conversation references), and the model list, model details, skill list, suggestions and sandbox gates read the authorization section inline once each. start_run now builds its run context in a worker and reuses that context's app_config for the rest of the start, so one run start sees one snapshot. Scheduled dispatch resolves its recursion limit in a worker, run inputs take only the AgentRuns capability, and the route-scoped authorization helpers run through asyncio.to_thread, as /api/models/resolve already did. * docs(changelog): reference #8137 for the off-loop run and authorization config reads * fix(gateway): validate the tool approval mode against the run's config snapshot The approval-mode check added on main read get_app_config() inline in start_run, so every run start revalidated config.yaml on the event loop again. It now uses the snapshot the run context was built with, like the model allowlist and the approval switch. * test(gateway): seed the skill catalog the authorization anchor lists The skill listing anchor relied on the checkout's skills/public tree containing academic-paper-review. It now writes one allowed and one hidden skill under its own skills.path, so it passes without the repo catalog and still fails if the policy is not applied.
194 lines
9.7 KiB
Python
194 lines
9.7 KiB
Python
"""HTTP admission and ordinary AgentRuns dispatch for in-flight user inputs."""
|
|
|
|
import copy
|
|
import json
|
|
import logging
|
|
from typing import Literal
|
|
from uuid import NAMESPACE_URL, uuid5
|
|
|
|
from fastapi import HTTPException, Request
|
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
|
|
from app.gateway.deps import get_agent_runs, get_current_user, get_run_manager
|
|
from deerflow.runtime.runs.inputs import InputCancelled, InputConflict, RunInputs
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class RunInputRequest(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
request_id: str = Field(min_length=1, max_length=96)
|
|
text: str = Field(min_length=1, max_length=16 * 1024)
|
|
mode: Literal["queue", "steer"]
|
|
context: dict = Field(default_factory=dict)
|
|
|
|
@model_validator(mode="after")
|
|
def bounded_context(self):
|
|
if len(json.dumps(self.context, ensure_ascii=True, allow_nan=False).encode()) > 32 * 1024:
|
|
raise ValueError("Input context exceeds 32 KiB")
|
|
return self
|
|
|
|
|
|
def _store(request):
|
|
store = get_run_manager(request).input_store
|
|
if store is None:
|
|
raise HTTPException(503, "Run inputs are unavailable")
|
|
return store
|
|
|
|
|
|
async def submit_run_input(thread_id: str, run_id: str, body: RunInputRequest, request: Request):
|
|
manager = get_run_manager(request)
|
|
user_id = await get_current_user(request)
|
|
if user_id is None:
|
|
raise HTTPException(401, "Authentication required")
|
|
run = await manager.get(run_id, user_id=user_id)
|
|
if run is None or run.thread_id != thread_id:
|
|
raise HTTPException(404, "Run not found")
|
|
if get_agent_runs(request) is None:
|
|
raise HTTPException(503, "Run inputs require an authenticated session")
|
|
row = await _store(request).get(run_id, user_id=user_id)
|
|
if row is None or row.get("run_inputs") is None:
|
|
raise HTTPException(409, "This Run has not enabled interactive inputs")
|
|
try:
|
|
from app.gateway.services import _CONTEXT_CONFIGURABLE_KEYS
|
|
from deerflow.runtime.run_origin import admit_origin
|
|
|
|
context = {key: value for key, value in body.context.items() if key in _CONTEXT_CONFIGURABLE_KEYS and key not in ("agent_name", "is_bootstrap")}
|
|
if isinstance(body.context.get("locale"), str):
|
|
context["locale"] = body.context["locale"]
|
|
source = "extension" if admit_origin(getattr(request.state, "run_origin", None)) is not None else "user"
|
|
item = await RunInputs(_store(request), run_id, user_id).submit(body.request_id, body.text, body.mode, context=context, source=source)
|
|
except InputConflict as exc:
|
|
raise HTTPException(409, str(exc)) from exc
|
|
return {**item, "target_run_id": run_id}
|
|
|
|
|
|
async def list_thread_inputs(thread_id: str, request: Request):
|
|
user_id = await get_current_user(request)
|
|
if user_id is None:
|
|
raise HTTPException(401, "Authentication required")
|
|
rows = await _store(request).list_input_runs(thread_id, user_id=user_id)
|
|
items = []
|
|
for row in reversed(rows):
|
|
for entry in (row.get("run_inputs") or {}).get("items", []):
|
|
status = entry["status"]
|
|
if status in ("pending", "applying") and (row["status"] not in ("pending", "running", "success") or row.get("stop_reason")):
|
|
status = "not_applied"
|
|
items.append({key: value for key, value in {**entry, "status": status, "target_run_id": row["run_id"]}.items() if key != "context"})
|
|
# Rank unresolved inputs (pending / not_applied / ...) ahead of terminal
|
|
# applied/cancelled receipts, prefer the newest entry within each group,
|
|
# cap at 32 records, then restore chronological order. A plain items[-32:]
|
|
# slice lets two full batches of receipts hide an older retryable input
|
|
# (issue #7972). If more than 32 inputs are unresolved, this deliberately
|
|
# returns 32 unresolved rows and no receipts for submission reconciliation;
|
|
# that trade-off follows the priority the issue asks for.
|
|
keep = sorted(range(len(items)), key=lambda i: (items[i]["status"] in ("applied", "cancelled"), -i))[:32]
|
|
return {"items": [items[i] for i in sorted(keep)], "active_run_id": await _store(request).active_input_run(thread_id, user_id=user_id)}
|
|
|
|
|
|
async def dispatch_queued_inputs(inputs: RunInputs, *, agent_runs, thread_id: str):
|
|
"""Use normal admission/authorization and its stable idempotency key.
|
|
|
|
This runs after the previous worker has fully finalized. No second main
|
|
Agent, task queue service or recovery scheduler is introduced.
|
|
"""
|
|
row = await inputs.store.get(inputs.run_id, user_id=inputs.user_id)
|
|
if row is None or row["status"] != "success" or row.get("stop_reason"):
|
|
return
|
|
state = await inputs.read()
|
|
for entry in state["items"]:
|
|
if entry["mode"] != "queue" or entry["status"] != "pending":
|
|
continue
|
|
try:
|
|
try:
|
|
run = await agent_runs.dispatch_input(thread_id=thread_id, run_id=inputs.run_id, input_id=entry["id"])
|
|
except InputCancelled:
|
|
# Cancellation can win after the snapshot, even during
|
|
# admission. The atomic claim proves no Run was started.
|
|
logger.info("Skipping cancelled queued input %s for Run %s", entry["id"], inputs.run_id)
|
|
continue
|
|
while run.status in ("pending", "running"):
|
|
# AgentRuns.wait retains its capped backoff and revocation
|
|
# checks; a long task exceeding a wait window is still live.
|
|
try:
|
|
run = await agent_runs.wait(thread_id=thread_id, run_id=run.run_id, timeout=60)
|
|
except TimeoutError:
|
|
continue
|
|
if run.status != "success" or run.stop_reason:
|
|
await inputs.pause()
|
|
return
|
|
except Exception:
|
|
# Unknown admission outcomes retain the original key and input;
|
|
# retrying can reconcile the same Run instead of running it twice.
|
|
logger.warning("Queued input remains undelivered for Run %s", inputs.run_id, exc_info=True)
|
|
return
|
|
|
|
|
|
async def dispatch_one_input(thread_id, run_id, input_id, request, *, retry=False):
|
|
from deerflow_extension_api.agent_runs import AgentRun
|
|
|
|
from app.gateway.routers.thread_runs import create_run, get_run
|
|
from app.gateway.run_models import RunCreateRequest
|
|
from app.gateway.services import resolve_thread_assistant_id
|
|
|
|
user_id = await get_current_user(request)
|
|
if user_id is None:
|
|
raise HTTPException(401, "Authentication required")
|
|
source = await _store(request).get(run_id, user_id=user_id)
|
|
if source is None or source["thread_id"] != thread_id:
|
|
raise HTTPException(404, "Run not found")
|
|
inputs = RunInputs(_store(request), run_id, user_id)
|
|
entry = await inputs.claim(input_id, retry=retry)
|
|
if entry.get("run_id"):
|
|
run = await get_run(thread_id, entry["run_id"], request)
|
|
return AgentRun(thread_id, run.run_id, run.status, run.assistant_id, run.stop_reason)
|
|
message_id = str(uuid5(NAMESPACE_URL, f"deerflow-dispatch:{inputs.run_id}:{input_id}"))
|
|
context = entry.get("context", {})
|
|
source_config = (source.get("kwargs") or {}).get("config") or {}
|
|
limit = source_config.get("recursion_limit")
|
|
config = {"recursion_limit": limit} if type(limit) is int else None
|
|
# Carry only the parent's admitted retrieval scope, never its files,
|
|
# credentials or framework markers. Normal admission rechecks access.
|
|
from deerflow.knowledge_scope import KNOWLEDGE_SCOPE_KEY
|
|
|
|
scope = None
|
|
for message in ((source.get("kwargs") or {}).get("input") or {}).get("messages", []):
|
|
if message.get("type", message.get("role")) in ("human", "user"):
|
|
scope = message.get("additional_kwargs", {}).get(KNOWLEDGE_SCOPE_KEY)
|
|
message = {"role": "user", "content": entry["text"], "id": message_id}
|
|
if scope is not None:
|
|
message["additional_kwargs"] = {KNOWLEDGE_SCOPE_KEY: copy.deepcopy(scope)}
|
|
if entry.get("source", "user") == "user":
|
|
# This fact comes from the stored, authenticated input, not from text
|
|
# or an arbitrary extension-supplied metadata flag.
|
|
request.state.run_origin = None
|
|
body = RunCreateRequest(assistant_id=await resolve_thread_assistant_id(request, thread_id, fail_closed=True), input={"messages": [message]}, context=context, config=config, on_disconnect="continue")
|
|
run = await create_run(thread_id, body, request, idempotency_key=f"run-input:{run_id}:{input_id}")
|
|
await inputs.dispatched(entry["id"], run.run_id)
|
|
return AgentRun(thread_id, run.run_id, run.status, run.assistant_id, run.stop_reason)
|
|
|
|
|
|
async def change_run_input(thread_id: str, run_id: str, input_id: str, request: Request, *, retry=False):
|
|
user_id = await get_current_user(request)
|
|
if user_id is None:
|
|
raise HTTPException(401, "Authentication required")
|
|
run = await get_run_manager(request).get(run_id, user_id=user_id)
|
|
if run is None or run.thread_id != thread_id:
|
|
raise HTTPException(404, "Run not found")
|
|
inputs = RunInputs(_store(request), run_id, user_id)
|
|
try:
|
|
if retry:
|
|
handle = get_agent_runs(request)
|
|
if handle is None:
|
|
raise HTTPException(503, "Run inputs require an authenticated session")
|
|
from deerflow.runtime.run_origin import admit_origin
|
|
|
|
if admit_origin(getattr(request.state, "run_origin", None)) is not None:
|
|
raise HTTPException(403, "An unapplied input requires an explicit user retry")
|
|
dispatched = await dispatch_one_input(thread_id, run_id, input_id, request, retry=True)
|
|
return {"run_id": dispatched.run_id}
|
|
await inputs.cancel(input_id)
|
|
return {"removed": True}
|
|
except InputConflict as exc:
|
|
raise HTTPException(409, str(exc)) from exc
|