From a246acff6d6ba95e3d1d45c1f058fab66ce5732e Mon Sep 17 00:00:00 2001 From: Paulo Date: Sun, 19 Jul 2026 17:55:28 +0200 Subject: [PATCH 01/21] Agent services, agent-tagged routes, and the gate answer receipt Adds the eight agent-facing operations as services on their owning packages (build/agent.py, durable/agent.py, usage/agent.py) with thin tags=["agent"] routes, a closed {code, message, retryable} error taxonomy, and byte-budgeted responses. The gate answer receipt is durable_runs.answered_parked_at, written SQL-side inside _park's RUNNING transition on the answer path only; answer_gate reuses Run.resume's idempotency and reads the receipt for already_answered. The existing resume/transcript/board/usage routes move onto the same services with HTTP/service parity, and read_transcript_chunk gains one shared UTF-8-safe read_slice. Workflow.start() keeps its bare run-id return; dispatch is idempotent and reports one note. --- backend/druks/api/agent.py | 70 ++ backend/druks/api/app.py | 13 + backend/druks/api/runs.py | 39 +- backend/druks/build/agent.py | 127 ++++ backend/druks/build/contracts.py | 15 +- backend/druks/build/exceptions.py | 16 + backend/druks/build/routes.py | 75 ++- backend/druks/build/schemas.py | 119 +++- backend/druks/durable/agent.py | 183 +++++ backend/druks/durable/exceptions.py | 57 ++ backend/druks/durable/models.py | 7 +- backend/druks/durable/reads.py | 107 ++- backend/druks/durable/schemas.py | 155 ++++- backend/druks/exceptions.py | 15 + backend/druks/usage/agent.py | 117 ++++ backend/druks/usage/routes.py | 81 +-- backend/druks/usage/schemas.py | 26 + backend/druks/workflows.py | 54 +- .../ff43df27a2e0_gate_answer_receipt.py | 29 + backend/tests/conftest.py | 2 + backend/tests/test_agent_routes.py | 351 ++++++++++ backend/tests/test_agent_services.py | 634 ++++++++++++++++++ backend/tests/test_contracts.py | 19 + backend/tests/test_gate_receipt.py | 86 +++ backend/tests/test_resume.py | 13 +- 25 files changed, 2287 insertions(+), 123 deletions(-) create mode 100644 backend/druks/api/agent.py create mode 100644 backend/druks/build/agent.py create mode 100644 backend/druks/build/exceptions.py create mode 100644 backend/druks/durable/agent.py create mode 100644 backend/druks/exceptions.py create mode 100644 backend/druks/usage/agent.py create mode 100644 backend/migrations/versions/ff43df27a2e0_gate_answer_receipt.py create mode 100644 backend/tests/test_agent_routes.py create mode 100644 backend/tests/test_agent_services.py create mode 100644 backend/tests/test_gate_receipt.py diff --git a/backend/druks/api/agent.py b/backend/druks/api/agent.py new file mode 100644 index 0000000..31478fd --- /dev/null +++ b/backend/druks/api/agent.py @@ -0,0 +1,70 @@ +from typing import Annotated + +from fastapi import APIRouter, Body +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + +from druks.durable import agent +from druks.durable.schemas import AgentCallDetail, CancelRunResult, GateAnswerResult, GateView + +router = APIRouter(prefix="/api/agent", tags=["agent"]) + + +class AnswerGateRequest(BaseModel): + # parkedAt echoes get_gate's response key unchanged — the park identity the + # answer must land on; one camelCase wire both directions. The rest mirrors + # ResumeRequest: a control the ask offered, an answer per open question, an + # optional free-text note. + model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel) + parked_at: AwareDatetime + control: str + answers: dict[str, str] = Field(default_factory=dict) + note: str = "" + + +@router.get( + "/gates/{run_id}", + operation_id="get_gate", + response_model=GateView, + response_model_by_alias=True, +) +async def get_gate(run_id: str) -> GateView: + return agent.get_gate(run_id) + + +@router.post( + "/gates/{run_id}/answer", + operation_id="answer_gate", + response_model=GateAnswerResult, + response_model_by_alias=True, +) +async def answer_gate(run_id: str, body: AnswerGateRequest) -> GateAnswerResult: + return await agent.answer_gate( + run_id, + parked_at=body.parked_at, + control=body.control, + answers=body.answers, + note=body.note, + ) + + +@router.get( + "/agent-calls/{call_id}", + operation_id="get_agent_call", + response_model=AgentCallDetail, + response_model_by_alias=True, +) +async def get_agent_call(call_id: str) -> AgentCallDetail: + return agent.get_agent_call(call_id) + + +@router.post( + "/runs/{run_id}/cancel", + operation_id="cancel_run", + response_model=CancelRunResult, + response_model_by_alias=True, +) +async def cancel_run( + run_id: str, reason: Annotated[str, Body(embed=True, min_length=1, max_length=500)] +) -> CancelRunResult: + return await agent.cancel_run(run_id, reason=reason) diff --git a/backend/druks/api/app.py b/backend/druks/api/app.py index 39c969d..b69edd5 100644 --- a/backend/druks/api/app.py +++ b/backend/druks/api/app.py @@ -11,11 +11,13 @@ from druks.accounts.dependencies import current_account from druks.accounts.routes import router as auth_router +from druks.api.agent import router as agent_router from druks.api.artifacts import router as artifacts_router from druks.api.runs import router as runs_router from druks.database import configure_session, create_engine_from_url, db_session from druks.durable.engine import init_dbos, launch, shutdown from druks.events.routes import router as events_router +from druks.exceptions import AgentApiError from druks.extensions.loader import iter_extensions, load from druks.mcp.catalog import load_mcp_catalog from druks.mcp.routes import router as mcp_router @@ -128,6 +130,16 @@ async def _http_exception_handler(request: Request, exc: HTTPException) -> JSONR ) +# The agent surface's one error shape. Messages are authored for the caller — +# the handler never serializes tracebacks or internals. +@app.exception_handler(AgentApiError) +async def _agent_api_error_handler(request: Request, exc: AgentApiError) -> JSONResponse: + return JSONResponse( + status_code=exc.status_code, + content={"code": exc.code, "message": str(exc), "retryable": exc.retryable}, + ) + + @app.exception_handler(RequestValidationError) async def _validation_exception_handler( request: Request, @@ -185,6 +197,7 @@ async def _unhandled_exception_handler( app.include_router(notifications_router, dependencies=_session_gate) app.include_router(events_router, dependencies=_session_gate) app.include_router(runs_router, dependencies=_session_gate) +app.include_router(agent_router, dependencies=_session_gate) app.include_router(artifacts_router, dependencies=_session_gate) load(app) diff --git a/backend/druks/api/runs.py b/backend/druks/api/runs.py index 3c4cc8b..5eb5cb2 100644 --- a/backend/druks/api/runs.py +++ b/backend/druks/api/runs.py @@ -1,9 +1,14 @@ from fastapi import APIRouter, HTTPException, status from druks.api.schemas import ResumeRequest -from druks.notifications.exceptions import InvalidChoiceError -from druks.notifications.services import validate_in_app_answer -from druks.workflows import Run, RunState +from druks.durable import agent +from druks.durable.exceptions import ( + GateNotAnswerable, + GateNotOpen, + GateRoundStale, + InvalidGateAnswer, +) +from druks.workflows import Run router = APIRouter(prefix="/api/runs", tags=["runs"]) @@ -11,15 +16,31 @@ @router.post("/{run_id}/resume", status_code=status.HTTP_204_NO_CONTENT) async def resume_run(run_id: str, body: ResumeRequest) -> None: # The in-app half of a gate: the operator answers the parked run from Druks - # (external gates resume through their own webhook). + # (external gates resume through their own webhook). The dashboard always + # answers the live park, so this echoes the run's own parked_at into the + # shared answer service and keeps its historical wire contract (204 / 409 / + # 422) over the agent taxonomy. run = Run.get(run_id) if not run: raise HTTPException(status.HTTP_404_NOT_FOUND, "run not found") - ask = run.input_request - if run.state != RunState.PENDING_INPUT.value or not ask: + parked_at = run.input_requested_at + if not parked_at: raise HTTPException(status.HTTP_409_CONFLICT, "run is not waiting on an in-app decision") try: - resume_payload = validate_in_app_answer(ask, body.control, body.answers, body.note) - except InvalidChoiceError as error: + result = await agent.answer_gate( + run_id, + parked_at=parked_at, + control=body.control, + answers=body.answers, + note=body.note, + ) + except (GateNotOpen, GateRoundStale) as error: + raise HTTPException( + status.HTTP_409_CONFLICT, "run is not waiting on an in-app decision" + ) from error + except (GateNotAnswerable, InvalidGateAnswer) as error: raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(error)) from error - await run.resume(**resume_payload) + if result.result == "already_answered": + # This park was already resumed; the double-submit stays the conflict + # this route has always reported. + raise HTTPException(status.HTTP_409_CONFLICT, "run is not waiting on an in-app decision") diff --git a/backend/druks/build/agent.py b/backend/druks/build/agent.py new file mode 100644 index 0000000..9adc985 --- /dev/null +++ b/backend/druks/build/agent.py @@ -0,0 +1,127 @@ +# Build's half of the agent surface: the work board, one item's detail, and +# dispatch — shared by the /api/build/agent routes and the MCP tools. +import base64 +from datetime import datetime + +from sqlalchemy import select, tuple_ + +from druks.accounts import sessions +from druks.accounts.models import Account +from druks.build.exceptions import InvalidCursor, WorkItemNotFound +from druks.build.models import WorkItem +from druks.build.schemas import ( + AgentDispatchResult, + AgentWorkItem, + AgentWorkItemDetail, + AgentWorkPage, +) +from druks.build.workflows import BuildWorkflow +from druks.database import db_session +from druks.durable.enums import ACTIVE_STATES, RunState +from druks.durable.models import Run +from druks.durable.reads import get_subject_status, list_recent_runs + +# Sized so a page of maximal rows serializes under the 12KiB budget. +_PAGE_LIMIT = 12 +_RECENT_RUNS_LIMIT = 5 +_RUN_CALLS_LIMIT = 5 + +# The lifecycle filters, as predicates on the item's build run — the dedup +# anchor and resume handle. "mine" is the caller's runs; the rest read the +# run's derived state. +_STATE_FILTERS = { + "parked": (RunState.PENDING_INPUT.value,), + "active": tuple(state.value for state in ACTIVE_STATES), + "failed": (RunState.FAILED.value,), +} + + +def list_work( + account: Account, *, filter: str | None = None, cursor: str | None = None +) -> AgentWorkPage: + stmt = ( + select(WorkItem) + .order_by(WorkItem.updated_at.desc(), WorkItem.id.desc()) + .limit(_PAGE_LIMIT + 1) + ) + if filter: + stmt = stmt.join(Run, Run.id == WorkItem.build_run_id) + if filter == "mine": + stmt = stmt.where(Run.account_id == account.id) + else: + stmt = stmt.where(Run.state.in_(_STATE_FILTERS[filter])) + if cursor: + after_at, after_id = _decode_cursor(cursor) + stmt = stmt.where(tuple_(WorkItem.updated_at, WorkItem.id) < (after_at, after_id)) + items = list(db_session().scalars(stmt)) + next_cursor = None + if len(items) > _PAGE_LIMIT: + items = items[:_PAGE_LIMIT] + next_cursor = _encode_cursor(items[-1]) + return AgentWorkPage( + items=[ + AgentWorkItem.from_work_item(item, get_subject_status("work_item", str(item.id))) + for item in items + ], + next_cursor=next_cursor, + ) + + +def get_work_item(work_item_id: int) -> AgentWorkItemDetail: + item = WorkItem.get(work_item_id) + if not item: + raise WorkItemNotFound(str(work_item_id)) + subject_id = str(item.id) + return AgentWorkItemDetail.from_work_item( + item, + get_subject_status("work_item", subject_id), + list_recent_runs( + "work_item", subject_id, limit=_RECENT_RUNS_LIMIT, calls_limit=_RUN_CALLS_LIMIT + ), + ) + + +async def dispatch( + account: Account, + *, + work_item_id: int | None = None, + source: str | None = None, + ticket_ref: str | None = None, +) -> AgentDispatchResult: + # The route holds the addressing to exactly one mode; both resolve an + # existing item — the closed error taxonomy has no room for intake here. + if work_item_id: + item = WorkItem.get(work_item_id) + ref = str(work_item_id) + else: + item = WorkItem.get_for_remote_key(source=source or "", remote_key=ticket_ref or "") + ref = f"{source}:{ticket_ref}" + if not item: + raise WorkItemNotFound(ref) + # Not every caller arrives through the session gate (the MCP boundary has + # none), so the service stamps ambient attribution itself — start() inherits + # it when the item carries no assignee. + sessions.current_account_id.set(account.id) + run_id = await BuildWorkflow.dispatch(work_item_id=item.id) + run = Run.get(run_id) + assert run is not None # dispatch just created (or handed back) this run's row + return AgentDispatchResult( + work_item_id=item.id, + run_id=run_id, + is_owned_by_caller=run.account_id == account.id, + note="run_id is the active build for this work item; dispatching again returns it.", + ) + + +def _encode_cursor(item: WorkItem) -> str: + key = f"{item.updated_at.isoformat()}|{item.id}" + return base64.urlsafe_b64encode(key.encode()).decode() + + +def _decode_cursor(cursor: str) -> tuple[datetime, int]: + try: + updated_at, _, item_id = base64.urlsafe_b64decode(cursor.encode()).decode().partition("|") + return datetime.fromisoformat(updated_at), int(item_id) + except ValueError as error: + # Covers base64, UTF-8, timestamp, and int parse failures alike. + raise InvalidCursor() from error diff --git a/backend/druks/build/contracts.py b/backend/druks/build/contracts.py index 5bcaeda..173f584 100644 --- a/backend/druks/build/contracts.py +++ b/backend/druks/build/contracts.py @@ -50,14 +50,17 @@ def to_result(self) -> dict[str, Any]: class QuestionOptionOutput(AgentOutput): - id: str - label: str + # Identity and cardinality are contract-capped at the agent boundary so a + # parked ask (and the gate view built from it) is bounded by construction — + # an oversized answer fails parse loudly instead of being clipped later. + id: str = Field(max_length=64) + label: str = Field(max_length=256) class QuestionOutput(AgentOutput): - id: str - prompt: str - options: list[QuestionOptionOutput] + id: str = Field(max_length=64) + prompt: str = Field(max_length=2048) + options: list[QuestionOptionOutput] = Field(max_length=16) class AcceptanceCriterionOutput(AgentOutput): @@ -95,7 +98,7 @@ def get_answered(self, picks: dict[str, str]) -> list[dict[str, str]]: class PlanOutput(AgentOutput): plan_markdown: str acceptance_criteria: list[AcceptanceCriterionOutput] - questions: list[QuestionOutput] + questions: list[QuestionOutput] = Field(max_length=8) def get_artifact(self) -> dict[str, str]: return {"kind": "markdown", "title": "Implementation plan", "content": self.plan_markdown} diff --git a/backend/druks/build/exceptions.py b/backend/druks/build/exceptions.py new file mode 100644 index 0000000..f091486 --- /dev/null +++ b/backend/druks/build/exceptions.py @@ -0,0 +1,16 @@ +from druks.exceptions import AgentApiError + + +class InvalidCursor(AgentApiError): + code = "INVALID_CURSOR" + + def __init__(self) -> None: + super().__init__("The cursor is not one this API issued; restart from the first page.") + + +class WorkItemNotFound(AgentApiError): + status_code = 404 + code = "WORK_ITEM_NOT_FOUND" + + def __init__(self, ref: str) -> None: + super().__init__(f"No work item {ref}.") diff --git a/backend/druks/build/routes.py b/backend/druks/build/routes.py index 682f252..fca1980 100644 --- a/backend/druks/build/routes.py +++ b/backend/druks/build/routes.py @@ -1,11 +1,20 @@ import logging +from typing import Literal -from fastapi import APIRouter, Body, HTTPException, Query, Response, status +from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response, status +from fastapi.exceptions import RequestValidationError +from pydantic import BaseModel from sqlalchemy import func, select, update +from druks.accounts.dependencies import current_account +from druks.accounts.models import Account +from druks.build import agent from druks.build.models import Project, ProjectRepo, WorkItem from druks.build.schemas import ( AddProjectRepoRequest, + AgentDispatchResult, + AgentWorkItemDetail, + AgentWorkPage, CreateProjectRequest, DashboardItem, GitHubReposResponse, @@ -255,3 +264,67 @@ async def list_work_items_history( # reads the event log directly, already ordered newest-handoff-first and bounded. items = [DashboardItem.from_work_item(wi) for wi in WorkItem.list_handoff(limit=clamped)] return WorkItemsHistoryResponse(items=items) + + +# /api/build/agent the agent surface + +agent_router = APIRouter(prefix="/agent", tags=["agent"]) + + +class DispatchRequest(BaseModel): + # Address the item one way: its druks id, or its ticket in the tracker. + work_item_id: int | None = None + source: Literal["linear", "jira"] | None = None + ticket_ref: str | None = None + + +@agent_router.get( + "/work", + operation_id="list_work", + response_model=AgentWorkPage, + response_model_by_alias=True, +) +async def list_work( + filter: Literal["mine", "parked", "active", "failed"] | None = None, + cursor: str | None = None, + account: Account = Depends(current_account), +) -> AgentWorkPage: + return agent.list_work(account, filter=filter, cursor=cursor) + + +@agent_router.get( + "/work-items/{work_item_id}", + operation_id="get_work_item", + response_model=AgentWorkItemDetail, + response_model_by_alias=True, +) +async def get_work_item(work_item_id: int) -> AgentWorkItemDetail: + return agent.get_work_item(work_item_id) + + +@agent_router.post( + "/dispatch", + operation_id="dispatch", + response_model=AgentDispatchResult, + response_model_by_alias=True, +) +async def dispatch( + body: DispatchRequest, account: Account = Depends(current_account) +) -> AgentDispatchResult: + if bool(body.work_item_id) == bool(body.source and body.ticket_ref): + # The addressing XOR is request shape, so it wears Pydantic's envelope. + raise RequestValidationError( + [ + { + "loc": ("body",), + "msg": "Address exactly one of work_item_id, or source + ticket_ref.", + "type": "value_error", + } + ] + ) + return await agent.dispatch( + account, + work_item_id=body.work_item_id, + source=body.source, + ticket_ref=body.ticket_ref, + ) diff --git a/backend/druks/build/schemas.py b/backend/druks/build/schemas.py index ed96fdd..b8b1477 100644 --- a/backend/druks/build/schemas.py +++ b/backend/druks/build/schemas.py @@ -5,6 +5,7 @@ from druks.build.models import Project, ProjectRepo, WorkItem from druks.durable.enums import RunState +from druks.durable.schemas import RunSummary, SubjectStatus, clip from druks.schemas import BaseResponse from druks.workflows import SubjectSummary @@ -102,6 +103,17 @@ class Links(BaseResponse): pr: str | None = None ticket: str | None = None + @classmethod + def from_work_item(cls, item: WorkItem, *, ticket_clip: int | None = None) -> "Links": + # The tracker URL is client-supplied and unbounded; budgeted (agent) + # projections pass ticket_clip so a page holds its byte contract — the + # dashboard keeps the whole URL for click-through. + pr = f"https://github.com/{item.repo}/pull/{item.pr_number}" if item.pr_number else None + ticket = item.remote_url + if ticket_clip: + ticket = clip(ticket, ticket_clip) + return cls(repo=f"https://github.com/{item.repo}", pr=pr, ticket=ticket) + class WorkItemSummary(SubjectSummary): # The work item's domain header — what only build knows. Status (where it is @@ -124,8 +136,6 @@ class WorkItemSummary(SubjectSummary): @classmethod def from_work_item(cls, item: WorkItem) -> "WorkItemSummary": - repo_url = f"https://github.com/{item.repo}" - pr_url = f"https://github.com/{item.repo}/pull/{item.pr_number}" if item.pr_number else None return cls( id=str(item.id), source=item.source, # type: ignore[arg-type] @@ -138,7 +148,7 @@ def from_work_item(cls, item: WorkItem) -> "WorkItemSummary": branch=item.branch, created_at=item.created_at, updated_at=item.updated_at, - links=Links(repo=repo_url, pr=pr_url, ticket=item.remote_url), + links=Links.from_work_item(item), ) @@ -161,8 +171,6 @@ class DashboardItem(BaseResponse): @classmethod def from_work_item(cls, item: WorkItem) -> "DashboardItem": assert item.status is not None # History is terminal-only: status is always set. - repo_url = f"https://github.com/{item.repo}" - pr_url = f"https://github.com/{item.repo}/pull/{item.pr_number}" if item.pr_number else None label, outcome = _outcome_from_status(item.status) return cls( key=f"code:{item.id}", @@ -178,9 +186,108 @@ def from_work_item(cls, item: WorkItem) -> "DashboardItem": outcome=outcome, created_at=item.created_at, updated_at=item.updated_at, - links=Links(repo=repo_url, pr=pr_url, ticket=item.remote_url), + links=Links.from_work_item(item), ) class WorkItemsHistoryResponse(BaseResponse): items: list[DashboardItem] + + +# The agent surface clips titles so a page of rows holds its byte budget. +_TITLE_CLIP = 120 +# SubjectStatus.failure is unbounded on the dashboard; the agent surface +# bounds it the same way the run summaries bound theirs. +_FAILURE_CLIP = 160 +# The tracker URL is client-supplied; real ticket URLs sit well under this. +_URL_CLIP = 200 + + +def _bounded_status(status: SubjectStatus) -> SubjectStatus: + return status.model_copy(update={"failure": clip(status.failure, _FAILURE_CLIP)}) + + +class AgentWorkItem(BaseResponse): + # One board row for the agent surface: identity + links + the platform's + # SubjectStatus facts, free text clipped for the page budget. + work_item_id: int + title: str + source: str + ticket_ref: str | None = None + repo: str + pr_number: int | None = None + branch: str | None = None + # The handoff lane at rest (scoped/shipped/skipped/cancelled); None while + # the item is in flight. + lane: str | None = None + links: Links + status: SubjectStatus + updated_at: datetime + + @classmethod + def from_work_item(cls, item: WorkItem, status: SubjectStatus) -> "AgentWorkItem": + return cls( + work_item_id=item.id, + title=clip(item.title, _TITLE_CLIP) or "", + source=item.source, + ticket_ref=item.remote_key, + repo=item.repo, + pr_number=item.pr_number, + branch=item.branch, + lane=item.status, + links=Links.from_work_item(item, ticket_clip=_URL_CLIP), + status=_bounded_status(status), + updated_at=item.updated_at, + ) + + +class AgentWorkPage(BaseResponse): + items: list[AgentWorkItem] = Field(default_factory=list) + # Opaque keyset position; absent on the last page. + next_cursor: str | None = None + + +class AgentWorkItemDetail(BaseResponse): + work_item_id: int + title: str + source: str + project_name: str + ticket_ref: str | None = None + repo: str + pr_number: int | None = None + branch: str | None = None + lane: str | None = None + links: Links + status: SubjectStatus + created_at: datetime + updated_at: datetime + # Newest first, each with its latest agent calls. + runs: list[RunSummary] = Field(default_factory=list) + + @classmethod + def from_work_item( + cls, item: WorkItem, status: SubjectStatus, runs: list[RunSummary] + ) -> "AgentWorkItemDetail": + return cls( + work_item_id=item.id, + title=clip(item.title, _TITLE_CLIP) or "", + source=item.source, + project_name=item.project.name, + ticket_ref=item.remote_key, + repo=item.repo, + pr_number=item.pr_number, + branch=item.branch, + lane=item.status, + links=Links.from_work_item(item, ticket_clip=_URL_CLIP), + status=_bounded_status(status), + created_at=item.created_at, + updated_at=item.updated_at, + runs=runs, + ) + + +class AgentDispatchResult(BaseResponse): + work_item_id: int + run_id: str + is_owned_by_caller: bool + note: str diff --git a/backend/druks/durable/agent.py b/backend/druks/durable/agent.py new file mode 100644 index 0000000..4824b75 --- /dev/null +++ b/backend/druks/durable/agent.py @@ -0,0 +1,183 @@ +# The durable half of the agent surface: gate read/answer, bounded call +# detail, and run cancel — shared by the /api/agent routes and the MCP tools. +from datetime import datetime +from typing import Any + +from druks.database import db_session +from druks.durable.enums import RunState +from druks.durable.exceptions import ( + AgentCallNotFound, + GateNotAnswerable, + GateNotOpen, + GateRoundStale, + InvalidGateAnswer, + RunNotActive, + RunNotFound, +) +from druks.durable.models import AgentCall, Artifact, Run +from druks.durable.reads import read_slice +from druks.durable.schemas import ( + AgentCallDetail, + AgentCallSummary, + ArtifactChunk, + CancelRunResult, + GateAnswerResult, + GateView, + clip, +) +from druks.notifications.exceptions import InvalidChoiceError +from druks.notifications.services import validate_in_app_answer + +_TRANSCRIPT_TAIL_BYTES = 8 * 1024 +_STDERR_TAIL_BYTES = 4 * 1024 +_ARTIFACT_CHUNK_BYTES = 4 * 1024 +_NOTE_BYTES = 2 * 1024 +_PROMPT_CLIP = 2 * 1024 +_OPTION_LABEL_CLIP = 256 + + +def _bounded_ask(ask: dict[str, Any]) -> dict[str, Any]: + # The ask is agent-authored free text, so the view bounds each field like + # every other budgeted response. Question COUNT stays whole — dropping a + # question would make the gate unanswerable; real asks hold a handful. + questions = [ + { + **question, + "prompt": clip(question.get("prompt"), _PROMPT_CLIP), + "options": [ + {**option, "label": clip(option.get("label"), _OPTION_LABEL_CLIP)} + for option in question.get("options", []) + ], + } + for question in ask.get("questions", []) + ] + return {**ask, "questions": questions} + + +def get_gate(run_id: str) -> GateView: + run = Run.get(run_id) + if not run: + raise RunNotFound(run_id) + if run.state != RunState.PENDING_INPUT.value: + raise GateNotOpen(run_id) + ask = run.input_request + if not ask or ask.get("presentation") != "in_app": + # External gates are answered on their source (PR review, ticket + # comment); an ask-less park offers no reply contract either. + raise GateNotAnswerable(run_id) + ask = _bounded_ask(run.get_ask()) + return GateView( + run_id=run.id, + gate=run.input_gate, # type: ignore[arg-type] + parked_at=run.input_requested_at, # type: ignore[arg-type] + ask=ask, + artifact=_artifact_chunk(Artifact.get_latest_for_run(run.id)), + reply_schema=_reply_schema(ask), + ) + + +async def answer_gate( + run_id: str, *, parked_at: datetime, control: str, answers: dict[str, str], note: str +) -> GateAnswerResult: + run = Run.get(run_id) + if not run: + raise RunNotFound(run_id) + # Same freshness discipline as notifications/services.py: the answer must + # land on the run's live park, so the comparison reads fresh. + db_session().expire(run) + if run.answered_parked_at == parked_at: + # The receipt names the parked_at an answer already resumed — a late + # or duplicate answer collapses here instead of reading "not open". + return GateAnswerResult(run_id=run.id, parked_at=parked_at, result="already_answered") + if run.state != RunState.PENDING_INPUT.value: + raise GateNotOpen(run_id) + if run.input_requested_at != parked_at: + raise GateRoundStale(run_id) + ask = run.input_request + if not ask or ask.get("presentation") != "in_app": + raise GateNotAnswerable(run_id) + if len(note.encode()) > _NOTE_BYTES: + raise InvalidGateAnswer(f"note exceeds {_NOTE_BYTES} bytes") + try: + payload = validate_in_app_answer(run.get_ask(), control, answers, note) + except InvalidChoiceError as error: + # The one validation authority stays in notifications; this surface + # speaks the agent taxonomy. + raise InvalidGateAnswer(str(error)) from error + # Run.resume keys the DBOS send by (gate, input_requested_at), so a + # concurrent duplicate answer to the same parked_at collapses engine-side. + await run.resume(**payload) + return GateAnswerResult(run_id=run.id, parked_at=parked_at, result="answered") + + +def get_agent_call(call_id: str) -> AgentCallDetail: + call = AgentCall.get(call_id) + if not call: + raise AgentCallNotFound(call_id) + layout = call.artifact_layout + return AgentCallDetail( + run_id=call.run_id, + call=AgentCallSummary.from_call(call), + transcript=read_slice( + layout.transcript, offset=-_TRANSCRIPT_TAIL_BYTES, limit=_TRANSCRIPT_TAIL_BYTES + ), + stderr=read_slice(layout.stderr, offset=-_STDERR_TAIL_BYTES, limit=_STDERR_TAIL_BYTES), + artifact=_artifact_chunk(Artifact.get_for_call(call.id)), + ) + + +async def cancel_run(run_id: str, *, reason: str) -> CancelRunResult: + run = Run.get(run_id) + if not run: + raise RunNotFound(run_id) + if run.state == RunState.CANCELLED.value: + return CancelRunResult(run_id=run.id, result="already_cancelled") + if not run.is_active: + raise RunNotActive(run_id) + await run.cancel(failure=reason) + return CancelRunResult(run_id=run.id, result="cancelled") + + +def _artifact_chunk(artifact: Artifact | None) -> ArtifactChunk | None: + if not artifact: + return + call = AgentCall.get(artifact.agent_call_id) + path = call.get_file_path(artifact.path) if call else None + if not path: + return + return ArtifactChunk( + call_id=artifact.agent_call_id, + kind=artifact.kind, + title=artifact.title, + chunk=read_slice(path, offset=0, limit=_ARTIFACT_CHUNK_BYTES), + ) + + +def _reply_schema(ask: dict[str, Any]) -> dict[str, Any]: + # What answer_gate accepts for this ask, as JSON Schema — the agent-facing + # twin of validate_in_app_answer: a control from the offered vocabulary, an + # answer per open question (an offered option id or the caller's own + # words), and a free-text note. + answers = { + # pattern \S: the service strips whitespace, so a blank answer is + # rejected — the schema says so up front. + question["id"]: {"type": "string", "pattern": r"\S", "description": question["prompt"]} + for question in ask.get("questions", []) + } + control: dict[str, Any] = {"type": "string", "enum": list(ask.get("controls", []))} + if "request_changes" in control["enum"]: + control["description"] = "request_changes needs an answer or a note to guide the re-plan." + return { + "type": "object", + "properties": { + "control": control, + "answers": {"type": "object", "properties": answers, "additionalProperties": False}, + "note": { + "type": "string", + "maxLength": _NOTE_BYTES, + "description": f"At most {_NOTE_BYTES} UTF-8 bytes.", + }, + }, + "required": ["control"], + "additionalProperties": False, + } diff --git a/backend/druks/durable/exceptions.py b/backend/druks/durable/exceptions.py index 083b3ec..355fcc9 100644 --- a/backend/druks/durable/exceptions.py +++ b/backend/druks/durable/exceptions.py @@ -1,5 +1,7 @@ from typing import ClassVar +from druks.exceptions import AgentApiError + class FatalError(Exception): """End the run as failed on purpose: the message becomes the run's recorded @@ -31,3 +33,58 @@ def __init__(self, gate: str) -> None: "to notify someone directly" ) self.gate = gate + + +class RunNotFound(AgentApiError): + status_code = 404 + code = "RUN_NOT_FOUND" + + def __init__(self, run_id: str) -> None: + super().__init__(f"No run {run_id}.") + + +class GateNotOpen(AgentApiError): + status_code = 409 + code = "GATE_NOT_OPEN" + + def __init__(self, run_id: str) -> None: + super().__init__(f"Run {run_id} is not parked on a gate.") + + +class GateRoundStale(AgentApiError): + status_code = 409 + code = "GATE_ROUND_STALE" + retryable = True + + def __init__(self, run_id: str) -> None: + super().__init__( + f"Run {run_id} has re-parked since the parked_at you read; fetch the gate again." + ) + + +class GateNotAnswerable(AgentApiError): + status_code = 409 + code = "GATE_NOT_ANSWERABLE" + + def __init__(self, run_id: str) -> None: + super().__init__(f"The gate on run {run_id} is answered on its source, not through here.") + + +class InvalidGateAnswer(AgentApiError): + code = "INVALID_GATE_ANSWER" + + +class AgentCallNotFound(AgentApiError): + status_code = 404 + code = "AGENT_CALL_NOT_FOUND" + + def __init__(self, call_id: str) -> None: + super().__init__(f"No agent call {call_id}.") + + +class RunNotActive(AgentApiError): + status_code = 409 + code = "RUN_NOT_ACTIVE" + + def __init__(self, run_id: str) -> None: + super().__init__(f"Run {run_id} already ended.") diff --git a/backend/druks/durable/models.py b/backend/druks/durable/models.py index 08fd80f..c0d0b86 100644 --- a/backend/druks/durable/models.py +++ b/backend/druks/durable/models.py @@ -41,6 +41,11 @@ class Run(Base): # When the run last asked for input — a historical fact (input_gate says # whether it's still waiting), and the one transition DBOS doesn't stamp. input_requested_at: Mapped[datetime | None] = mapped_column(default=None) + # The receipt: the input_requested_at stamp an answer last resumed. Only + # the answer path writes it (cancel and timeout never do), so a late + # duplicate answer reads "already answered" instead of "not open". Wire + # name: parked_at. + answered_parked_at: Mapped[datetime | None] = mapped_column(default=None) failure: Mapped[str | None] = mapped_column(default=None) # The FatalError subtype's code when the run stopped on a deliberate domain # error, so read-sides tell e.g. a gate timeout from a crash without parsing @@ -279,7 +284,7 @@ def get_file_path(self, name: str) -> Path | None: try: candidate.relative_to(self.call_dir.resolve()) except ValueError: - return None + return return candidate if candidate.is_file() else None def get_stream_path(self, stream: Literal["stdout", "stderr"]) -> Path | None: diff --git a/backend/druks/durable/reads.py b/backend/druks/durable/reads.py index c5624c7..f29f7b5 100644 --- a/backend/druks/durable/reads.py +++ b/backend/druks/durable/reads.py @@ -2,6 +2,7 @@ # Schemas stay pure projections; routes call in here. import asyncio from collections.abc import AsyncIterator +from pathlib import Path from typing import Literal from sqlalchemy import Engine @@ -15,10 +16,12 @@ AgentCallFiles, AgentCallResponse, RunResponse, + RunSummary, SubjectActivity, SubjectResponse, SubjectStatus, SubjectSummary, + TextSlice, TranscriptChunk, ) @@ -30,7 +33,7 @@ def get_agent_call_files(call_id: str) -> AgentCallFiles | None: call = AgentCall.get(call_id) if not call: - return None + return return AgentCallFiles.from_call(call, Artifact.get_for_call(call.id)) @@ -47,6 +50,16 @@ def get_subject_status(subject_type: str, subject_id: str) -> SubjectStatus: return _status(runs, active_run, _running_calls(active_run)) +def list_recent_runs( + subject_type: str, subject_id: str, *, limit: int, calls_limit: int +) -> list[RunSummary]: + # The subject's newest runs, each with its latest agent calls — the bounded + # agent-surface cut of list_subject_timeline. + runs = Run.list_for_subject(subject_type, subject_id)[:limit] + calls_by_run = AgentCall.by_run([run.id for run in runs]) + return [RunSummary.from_run(run, calls_by_run[run.id][-calls_limit:]) for run in runs] + + def get_subject_response( subject_type: str, subject_id: str, @@ -113,6 +126,81 @@ def _status( ) +def _continuation_prefix(payload: bytes) -> int: + # Bytes of a split character's tail at the head of a mid-file slice. + length = 0 + while length < min(3, len(payload)) and payload[length] & 0xC0 == 0x80: + length += 1 + return length + + +# Leads whose first continuation is narrower than 80–BF (RFC 3629): E0/F0 +# exclude overlongs, ED excludes surrogates, F4 caps at U+10FFFF. +_FIRST_CONTINUATION = { + 0xE0: (0xA0, 0xBF), + 0xED: (0x80, 0x9F), + 0xF0: (0x90, 0xBF), + 0xF4: (0x80, 0x8F), +} + + +def _partial_suffix(payload: bytes) -> int: + # Bytes of an incomplete trailing multi-byte character; 0 when the slice + # ends on a character boundary. + for back in range(1, min(4, len(payload)) + 1): + byte = payload[-back] + if byte & 0xC0 == 0x80: + continue + # Only a sequence that can still complete is worth trimming; anything + # that never will (C0/C1 or F5+ leads, raw terminal noise, a first + # continuation outside the lead's constrained range — overlongs, + # surrogates, beyond U+10FFFF) is kept, replaced by decode(), and + # advanced past. + if 0xC2 <= byte <= 0xF4: + needed = 2 if byte < 0xE0 else 3 if byte < 0xF0 else 4 + if back < needed: + low, high = _FIRST_CONTINUATION.get(byte, (0x80, 0xBF)) + if back == 1 or low <= payload[-back + 1] <= high: + return back + return 0 + return 0 + + +def read_slice(path: Path, *, offset: int, limit: int) -> TextSlice: + # One bounded slice of a text file, snapped to UTF-8 character boundaries + # on both cut edges (the next read re-covers trimmed bytes). A negative + # offset addresses from the end — the tail |offset| bytes. Missing file: + # an empty eof slice. + if not path.exists(): + return TextSlice(offset=0, next_offset=0, eof=True, has_earlier=False, text="") + # Any single character fits, so boundary trims always leave progress — a + # smaller limit could sit on a four-byte character forever. + limit = max(limit, 4) + size = path.stat().st_size + if offset < 0: + offset = max(size + offset, 0) + with path.open("rb") as handle: + handle.seek(offset) + payload = handle.read(limit) + if offset: + lead = _continuation_prefix(payload) + offset += lead + payload = payload[lead:] + # Trim a partial trailing sequence unconditionally: at EOF it means the + # writer is mid-character, and emitting � would advance past bytes whose + # completion the next read must re-cover. next_offset stays behind the + # trim, so eof reads false until the character lands whole. + payload = payload[: len(payload) - _partial_suffix(payload)] + next_offset = offset + len(payload) + return TextSlice( + offset=offset, + next_offset=next_offset, + eof=next_offset >= size, + has_earlier=offset > 0, + text=payload.decode("utf-8", errors="replace"), + ) + + def read_transcript_chunk( engine: Engine, call_id: str, @@ -127,23 +215,20 @@ def read_transcript_chunk( with session_scope(engine): call = AgentCall.get(call_id) if not call: - return None + return path = call.get_stream_path(stream) - if not path or not path.exists(): + if not path: return TranscriptChunk( call_id=call_id, stream=stream, offset=offset, next_offset=offset, eof=True, text="" ) - with path.open("rb") as handle: - handle.seek(offset) - payload = handle.read(limit) - next_offset = offset + len(payload) + piece = read_slice(path, offset=offset, limit=limit) return TranscriptChunk( call_id=call_id, stream=stream, - offset=offset, - next_offset=next_offset, - eof=next_offset >= path.stat().st_size, - text=payload.decode("utf-8", errors="replace"), + offset=piece.offset, + next_offset=piece.next_offset, + eof=piece.eof, + text=piece.text, ) diff --git a/backend/druks/durable/schemas.py b/backend/druks/durable/schemas.py index 2df3e80..f817d2b 100644 --- a/backend/druks/durable/schemas.py +++ b/backend/druks/durable/schemas.py @@ -1,3 +1,4 @@ +import json from datetime import UTC, datetime from pathlib import Path from typing import Any, Literal @@ -30,6 +31,40 @@ def get_display_label(kind: str) -> str: return kind.rsplit(".", 1)[-1].replace("_", " ").capitalize() +def _wire_cost(char: str) -> int: + # What one character spends of a wire budget: its JSON-escaped UTF-8 bytes + # (a control byte like ESC serializes as  — six bytes, not one). + return len(json.dumps(char, ensure_ascii=False).encode()) - 2 + + +def clip(text: str | None, limit: int) -> str | None: + # Budgeted read-sides bound their free-text fields by what the field will + # occupy in the serialized response, so the budgets hold for multibyte and + # escape-heavy text alike. The ellipsis (3 bytes) marks the cut. + if not text: + return text + total = 0 + cut = None + for index, char in enumerate(text): + total += _wire_cost(char) + if cut is None and total > limit - 3: + cut = index + if total > limit: + return text[:cut] + "…" + return text + + +def _derived_status(call: AgentCall) -> str: + # Liveness is derived, not stored: an unfinished call reads "running" while its + # run is active and "abandoned" once the run is terminal (the run ended without + # the call closing). A finished call keeps its recorded outcome. + if call.finished_at: + return AgentCallStatus(call.status).value + if call.run.is_active: + return "running" + return "abandoned" + + class AgentCallResponse(BaseResponse): id: str # Which agent made this call ("scope", "implement") — the timeline's row label. @@ -48,21 +83,12 @@ class AgentCallResponse(BaseResponse): @classmethod def from_call(cls, call: AgentCall) -> "AgentCallResponse": - # Liveness is derived, not stored: an unfinished call reads "running" while its - # run is active and "abandoned" once the run is terminal (the run ended without - # the call closing). A finished call keeps its recorded outcome. - if call.finished_at: - status = AgentCallStatus(call.status).value - elif call.run.is_active: - status = "running" - else: - status = "abandoned" return cls( id=call.id, agent=call.agent, label=get_display_label(call.agent) if call.agent else "Agent", account_username=call.account.username, - status=status, # type: ignore[arg-type] + status=_derived_status(call), # type: ignore[arg-type] started_at=call.started_at, finished_at=call.finished_at, last_error=call.last_error, @@ -107,7 +133,7 @@ def from_call(cls, call: AgentCall, artifact: Artifact | None) -> "AgentCallFile def named(path: Path) -> ArtifactFile | None: if not path.is_file(): - return None + return stat = path.stat() return ArtifactFile( name=path.name, @@ -221,3 +247,110 @@ class TranscriptChunk(BaseResponse): next_offset: int eof: bool text: str + + +# Free-text fields on the agent-surface summaries are clipped to this, so a +# stack trace can't blow a response budget. +_TEXT_CLIP = 160 + + +class TextSlice(BaseResponse): + # One bounded UTF-8-safe cut of an on-disk text file; offsets are byte + # positions, has_earlier marks content before this slice. + offset: int + next_offset: int + eof: bool + has_earlier: bool + text: str + + +class AgentCallSummary(BaseResponse): + # The bounded agent-surface cut of AgentCallResponse: the same facts with + # clipped free text and no token breakdown. + id: str + agent: str | None = None + account_username: str + status: Literal["running", "succeeded", "failed", "abandoned"] + started_at: datetime + finished_at: datetime | None = None + last_error: str | None = None + cost_usd: float | None = None + + @classmethod + def from_call(cls, call: AgentCall) -> "AgentCallSummary": + return cls( + id=call.id, + agent=call.agent, + account_username=call.account.username, + status=_derived_status(call), # type: ignore[arg-type] + started_at=call.started_at, + finished_at=call.finished_at, + last_error=clip(call.last_error, _TEXT_CLIP), + cost_usd=call.cost_usd, + ) + + +class RunSummary(BaseResponse): + # One run for the agent surface, with its latest agent calls — the bounded + # cut of RunResponse (no ask payload; get_gate serves the live one). + id: str + kind: str + state: Literal["scheduled", "running", "pending_input", "finished", "failed", "cancelled"] + failure: str | None = None + created_at: datetime + updated_at: datetime + account_username: str + agent_calls: list[AgentCallSummary] = Field(default_factory=list) + + @classmethod + def from_run(cls, run: Run, calls: list[AgentCall]) -> "RunSummary": + return cls( + id=run.id, + kind=run.kind, + state=run.state, # type: ignore[arg-type] + failure=clip(run.failure, _TEXT_CLIP), + created_at=run.created_at, + updated_at=run.updated_at, + account_username=run.account.username, + agent_calls=[AgentCallSummary.from_call(call) for call in calls], + ) + + +class ArtifactChunk(BaseResponse): + # A call's renderable output, head-bounded; page the rest through the + # call's transcript files route. + call_id: str + kind: str + title: str + chunk: TextSlice + + +class GateView(BaseResponse): + # Everything needed to answer a parked run in one read: the ask, the + # artifact under review, the reply's JSON Schema, and parked_at — the park + # identity answer_gate must echo back. + run_id: str + gate: str + parked_at: datetime + ask: dict[str, Any] + artifact: ArtifactChunk | None = None + reply_schema: dict[str, Any] + + +class GateAnswerResult(BaseResponse): + run_id: str + parked_at: datetime + result: Literal["answered", "already_answered"] + + +class AgentCallDetail(BaseResponse): + run_id: str + call: AgentCallSummary + transcript: TextSlice + stderr: TextSlice + artifact: ArtifactChunk | None = None + + +class CancelRunResult(BaseResponse): + run_id: str + result: Literal["cancelled", "already_cancelled"] diff --git a/backend/druks/exceptions.py b/backend/druks/exceptions.py new file mode 100644 index 0000000..fef5dfe --- /dev/null +++ b/backend/druks/exceptions.py @@ -0,0 +1,15 @@ +from typing import ClassVar + + +class AgentApiError(Exception): + # Base for the agent surface's wire errors: each subclass names its HTTP + # status and stable code, serialized as the one {code, message, retryable} + # response shape. Messages are authored for the caller — never tracebacks, + # paths, or engine internals. retryable=True marks a failure the caller can + # fix by re-reading state and retrying. + status_code: ClassVar[int] = 400 + code: ClassVar[str] = "" + retryable: ClassVar[bool] = False + + +__all__ = ["AgentApiError"] diff --git a/backend/druks/usage/agent.py b/backend/druks/usage/agent.py new file mode 100644 index 0000000..4285d51 --- /dev/null +++ b/backend/druks/usage/agent.py @@ -0,0 +1,117 @@ +# Usage's half of the agent surface: one pure read of the caller's quota and +# today's spend — no poll trigger; refresh stays route-driven. The dashboard +# routes share the day-window read and the downsampler declared here. +from datetime import UTC, datetime, timedelta +from zoneinfo import ZoneInfo + +from sqlalchemy import Row, select + +from druks.accounts.models import Account +from druks.core.utils.time import operator_local_day +from druks.database import db_session +from druks.durable.models import AgentCall +from druks.durable.schemas import clip +from druks.harnesses.artifacts import normalize_token_usage +from druks.harnesses.models import HarnessConnection +from druks.harnesses.registry import get_harnesses +from druks.usage.models import UsageScrape +from druks.usage.schemas import AgentHarnessUsage, AgentUsage, UsageHistoryPoint +from druks.user_settings.models import UserSettings + +# Trend ranges for the percent-left sparklines. The 5h window gets one full +# window plus headroom so an exhaustion arc is visible end to end; weekly gets +# the whole week. +FIVE_HOUR_RANGE = timedelta(hours=6) +WEEK_RANGE = timedelta(days=7) + +# The agent read keeps each trend this short so the whole response stays +# within its byte budget. +_HISTORY_POINTS = 8 + + +def get_usage(account: Account) -> AgentUsage: + now = datetime.now(UTC) + timezone, local_start, rows = list_finished_calls_today(account.id) + spend = 0.0 + tokens = 0 + for _, cost_usd, cost_metadata, _ in rows: + if cost_usd is not None: + spend += float(cost_usd) + usage = normalize_token_usage(cost_metadata) + if usage: + tokens += usage["total_tokens"] + return AgentUsage( + day=local_start.date().isoformat(), + timezone=str(timezone), + spend_today_usd=round(spend, 4), + tokens_today=tokens, + runs_today=len(rows), + harnesses=[_harness_usage(h.name, account.id, now=now) for h in get_harnesses()], + ) + + +def list_finished_calls_today(account_id: str) -> tuple[ZoneInfo, datetime, list[Row]]: + # The account's finished calls in the operator-local day, as (timezone, + # local_start, rows of (model, cost_usd, cost_metadata, finished_at)) — + # the shared boundary keeps every spend-today figure identical. + timezone, local_start = operator_local_day(UserSettings.get().timezone, datetime.now(UTC)) + rows = ( + db_session() + .execute( + select( + AgentCall.model, + AgentCall.cost_usd, + AgentCall.cost_metadata, + AgentCall.finished_at, + ) + .where(AgentCall.account_id == account_id) + .where(AgentCall.finished_at.is_not(None)) + .where(AgentCall.finished_at >= local_start.astimezone(UTC)) + .where(AgentCall.finished_at < (local_start + timedelta(days=1)).astimezone(UTC)), + ) + .all() + ) + return timezone, local_start, rows + + +def downsample(points: list[UsageHistoryPoint], *, cap: int) -> list[UsageHistoryPoint]: + # Thin a series to ≤ cap points, always keeping the newest sample (the + # "now" anchor) — it replaces the last strided sample so the cap holds. + if len(points) <= cap: + return points + stride = -(-len(points) // cap) # ceil division + thinned = points[::stride] + thinned[-1] = points[-1] + return thinned + + +def _harness_usage(name: str, account_id: str, *, now: datetime) -> AgentHarnessUsage: + is_connected = bool(HarnessConnection.get_for_account(name, account_id)) + row = UsageScrape.latest_for(name, account_id) + if not row: + return AgentHarnessUsage(name=name, is_connected=is_connected) + history = UsageScrape.history_for(name, account_id, since=now - WEEK_RANGE) + five_hour_cutoff = now - FIVE_HOUR_RANGE + five_hour = [ + UsageHistoryPoint(t=point.scraped_at, pct=point.five_hour_percent_left) + for point in history + if point.five_hour_percent_left is not None and point.scraped_at >= five_hour_cutoff + ] + week = [ + UsageHistoryPoint(t=point.scraped_at, pct=point.week_percent_left) + for point in history + if point.week_percent_left is not None + ] + return AgentHarnessUsage( + name=name, + is_connected=is_connected, + plan_tier=clip(row.plan_tier, 64), + five_hour_percent_left=row.five_hour_percent_left, + five_hour_resets_at=row.five_hour_resets_at, + week_percent_left=row.week_percent_left, + week_resets_at=row.week_resets_at, + is_unlimited=row.unlimited, + scraped_at=row.scraped_at, + five_hour_history=downsample(five_hour, cap=_HISTORY_POINTS), + week_history=downsample(week, cap=_HISTORY_POINTS), + ) diff --git a/backend/druks/usage/routes.py b/backend/druks/usage/routes.py index 05761c4..b68945f 100644 --- a/backend/druks/usage/routes.py +++ b/backend/druks/usage/routes.py @@ -1,17 +1,17 @@ -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime from fastapi import APIRouter, Depends -from sqlalchemy import select from druks.accounts.dependencies import current_account from druks.accounts.models import Account -from druks.core.utils.time import operator_local_day -from druks.db import db_session from druks.harnesses.artifacts import normalize_token_usage from druks.harnesses.models import HarnessConnection from druks.harnesses.registry import get_harnesses +from druks.usage import agent +from druks.usage.agent import FIVE_HOUR_RANGE, WEEK_RANGE, downsample, list_finished_calls_today from druks.usage.models import UsageScrape from druks.usage.schemas import ( + AgentUsage, UsageHarnessHistory, UsageHarnessSummary, UsageHarnessToday, @@ -21,8 +21,7 @@ UsageResponse, UsageTodayResponse, ) -from druks.user_settings.models import HarnessSettings, UserSettings -from druks.workflows import AgentCall +from druks.user_settings.models import HarnessSettings router = APIRouter(tags=["usage"]) @@ -38,12 +37,7 @@ # default 5-min poll cadence. _STALE_AFTER_SECONDS = 24 * 60 * 60 -# Trend ranges for the usage page sparklines. The 5h window gets one -# full window plus headroom so an exhaustion arc is visible end to end; -# weekly gets the whole week. Both are downsampled to keep the payload -# flat regardless of poll cadence. -_FIVE_HOUR_RANGE = timedelta(hours=6) -_WEEK_RANGE = timedelta(days=7) +# The dashboard sparklines keep this many points regardless of poll cadence. _MAX_SPARK_POINTS = 72 @@ -96,28 +90,10 @@ async def get_usage_history(account: Account = Depends(current_account)) -> Usag response_model_by_alias=True, ) async def get_usage_today(account: Account = Depends(current_account)) -> UsageTodayResponse: - # The shared operator-local-day boundary keeps this total identical to - # the sys-strip's spend-today figure. - timezone_name = UserSettings.get().timezone - now = datetime.now(UTC) - timezone, local_start = operator_local_day(timezone_name, now) + # The shared operator-local-day read keeps this total identical to the + # sys-strip's and the agent surface's spend-today figures. + timezone, local_start, rows = list_finished_calls_today(account.id) timezone_name = str(timezone) - rows = ( - db_session() - .execute( - select( - AgentCall.model, - AgentCall.cost_usd, - AgentCall.cost_metadata, - AgentCall.finished_at, - ) - .where(AgentCall.account_id == account.id) - .where(AgentCall.finished_at.is_not(None)) - .where(AgentCall.finished_at >= local_start.astimezone(UTC)) - .where(AgentCall.finished_at < (local_start + timedelta(days=1)).astimezone(UTC)), - ) - .all() - ) # Every call counts, even one whose model no picker list claims (pinned # outside the list, or never resolved): money spent must not vanish from @@ -161,9 +137,24 @@ async def get_usage_today(account: Account = Depends(current_account)) -> UsageT ) +# /api/usage/agent the agent surface + +agent_router = APIRouter(tags=["agent"]) + + +@agent_router.get( + "/agent", + operation_id="get_usage", + response_model=AgentUsage, + response_model_by_alias=True, +) +async def get_agent_usage(account: Account = Depends(current_account)) -> AgentUsage: + return agent.get_usage(account) + + def _harness_history(name: str, account_id: str, *, now: datetime) -> UsageHarnessHistory: - rows = UsageScrape.history_for(name, account_id, since=now - _WEEK_RANGE) - five_hour_cutoff = now - _FIVE_HOUR_RANGE + rows = UsageScrape.history_for(name, account_id, since=now - WEEK_RANGE) + five_hour_cutoff = now - FIVE_HOUR_RANGE five_hour = [ UsageHistoryPoint(t=row.scraped_at, pct=row.five_hour_percent_left) for row in rows @@ -176,23 +167,11 @@ def _harness_history(name: str, account_id: str, *, now: datetime) -> UsageHarne ] return UsageHarnessHistory( name=name, - five_hour=_downsample(five_hour), - week=_downsample(week), + five_hour=downsample(five_hour, cap=_MAX_SPARK_POINTS), + week=downsample(week, cap=_MAX_SPARK_POINTS), ) -def _downsample(points: list[UsageHistoryPoint]) -> list[UsageHistoryPoint]: - """Thin a series to ≤ _MAX_SPARK_POINTS, always keeping the newest - sample (the page's "now" anchor).""" - if len(points) <= _MAX_SPARK_POINTS: - return points - stride = -(-len(points) // _MAX_SPARK_POINTS) # ceil division - thinned = points[::stride] - if thinned[-1] is not points[-1]: - thinned.append(points[-1]) - return thinned - - def _summarize( row: UsageScrape | None, *, @@ -221,11 +200,11 @@ def _summarize( def _metric(percent_left: int | None, resets_at: datetime | None) -> UsageMetricSummary | None: if percent_left is None and resets_at is None: - return None + return return UsageMetricSummary(percent_left=percent_left, resets_at=resets_at) def _age_seconds(scraped_at: datetime | None, *, now: datetime) -> int | None: if not scraped_at: - return None + return return max(0, int((now - scraped_at).total_seconds())) diff --git a/backend/druks/usage/schemas.py b/backend/druks/usage/schemas.py index 1868fb3..117dec5 100644 --- a/backend/druks/usage/schemas.py +++ b/backend/druks/usage/schemas.py @@ -85,3 +85,29 @@ class UsageTodayResponse(BaseResponse): day: str timezone: str harnesses: list[UsageHarnessToday] + + +class AgentHarnessUsage(BaseResponse): + # One harness's quota for the agent surface: the latest snapshot's facts + # plus a short percent-left trend per window, oldest first. + name: str + is_connected: bool = False + plan_tier: str | None = None + five_hour_percent_left: int | None = None + five_hour_resets_at: datetime | None = None + week_percent_left: int | None = None + week_resets_at: datetime | None = None + is_unlimited: bool = False + scraped_at: datetime | None = None + five_hour_history: list[UsageHistoryPoint] = Field(default_factory=list) + week_history: list[UsageHistoryPoint] = Field(default_factory=list) + + +class AgentUsage(BaseResponse): + # The caller's spend for the operator-local day plus per-harness quota. + day: str + timezone: str + spend_today_usd: float + tokens_today: int + runs_today: int + harnesses: list[AgentHarnessUsage] = Field(default_factory=list) diff --git a/backend/druks/workflows.py b/backend/druks/workflows.py index 2cf3ff3..254c7dc 100644 --- a/backend/druks/workflows.py +++ b/backend/druks/workflows.py @@ -143,7 +143,7 @@ def _input_model_from_signature(cls: type["Workflow"]) -> type[BaseModel] | None method = getattr(cls, method_name) parameters = [p for name, p in inspect.signature(method).parameters.items() if name != "self"] if not parameters: - return None + return hints = get_type_hints(method) fields: dict[str, Any] = {} for p in parameters: @@ -235,11 +235,16 @@ async def _park( payload = await DBOS.recv_async(topic, timeout_seconds=ttl_seconds) if payload is None: raise GateTimeout(topic) + # Only this path — an answer landing — writes the receipt. The value is the + # row's own parked stamp, copied SQL-side inside the memoized transition + # step: a body-local timestamp would drift from the stored stamp when a + # crash replays this stretch. Timeout raises above; a cancel raises out of + # recv; the FAILED emit clears the gate without the receipt. await _emit_run_event( workflow.workflow_id, RunState.RUNNING, subject=workflow.subject, - facts=_GATE_CLEARED, + facts={**_GATE_CLEARED, "answered_parked_at": Run.input_requested_at}, ) return payload @@ -251,9 +256,8 @@ async def _notify_designated_destination(workflow_id: str, subject: dict[str, An async def _create() -> str | None: async with step_session(): destination_id = UserSettings.get().gate_park_destination_id - if not destination_id: - return None - return Run.get(workflow_id).create_park_notification(destination_id, subject) + if destination_id: + return Run.get(workflow_id).create_park_notification(destination_id, subject) notification_id = await DBOS.run_step_async( StepOptions(name="notifications.gate_park", **_IO_RETRIES), _create @@ -306,14 +310,13 @@ async def _transition() -> dict[str, Any] | None: for field, value in facts.items(): setattr(run, field, value) session.flush() - if not subject: - # Subjectless framework crons are plumbing: no feed entry. - return None - return { - "kind": run.kind, - "subject": subject, - "payload": _log_run_event(run, event, subject, result), - } + # Subjectless framework crons are plumbing: no feed entry. + if subject: + return { + "kind": run.kind, + "subject": subject, + "payload": _log_run_event(run, event, subject, result), + } transition = await DBOS.run_step_async( StepOptions(name=f"run.{event.value}", **_IO_RETRIES), _transition @@ -554,7 +557,7 @@ async def _ensure_host(self) -> str | None: # The warm VM, provisioned once per segment; state is carried in git, so # only the host-id matters across steps — held-across-steps never fights replay. if not self.steps_reuse_sandbox: - return None + return if self._host and self._host.expires_at: remaining = (self._host.expires_at - datetime.now(UTC)).total_seconds() if remaining < SANDBOX_HOST_ROTATE_BEFORE_SECONDS: @@ -628,13 +631,13 @@ async def start( **input: Any, ) -> str: # Mint the id, write the projection row, enqueue the body. Returns the - # workflow id; an extension that wants one-active-run-per-subject enforces - # that on its own side before calling this. Enqueuing (not start_workflow) - # routes execution onto the shared queue, so the process that kicks a run - # off — often the web process — doesn't have to be the one that runs it; - # any launched executor picks it up. The input kwargs mirror the body's own - # signature and validate against the model synthesized from it, so a bad - # shape fails at start, not inside the run. + # run to follow — a freshly enqueued run, or the subject's already-live + # one handed back. Enqueuing (not start_workflow) routes execution + # onto the shared queue, so the process that kicks a run off — often the + # web process — doesn't have to be the one that runs it; any launched + # executor picks it up. The input kwargs mirror the body's own signature + # and validate against the model synthesized from it, so a bad shape + # fails at start, not inside the run. # subject is required (no default) so a run can't silently lose its # timeline by omission — pass subject=None explicitly for a background run. if account_id is None: @@ -643,12 +646,11 @@ async def start( account_id = current_account_id.get() if subject is not None: _Subject.model_validate(subject) # raises on a bad shape (wrong/extra keys, types) - if cls._run_input_model is None: - if input: - raise WorkflowError(f"{cls.__name__}.{cls._body_method}() takes no input") - wire: dict[str, Any] = {} - else: + wire: dict[str, Any] = {} + if cls._run_input_model: wire = cls._run_input_model.model_validate(input).model_dump(mode="json") + elif input: + raise WorkflowError(f"{cls.__name__}.{cls._body_method}() takes no input") if account_id: wire[_ACCOUNT_INPUT_KEY] = account_id workflow_id = str(uuid7()) diff --git a/backend/migrations/versions/ff43df27a2e0_gate_answer_receipt.py b/backend/migrations/versions/ff43df27a2e0_gate_answer_receipt.py new file mode 100644 index 0000000..2db5b78 --- /dev/null +++ b/backend/migrations/versions/ff43df27a2e0_gate_answer_receipt.py @@ -0,0 +1,29 @@ +"""gate answer receipt + +Revision ID: ff43df27a2e0 +Revises: 0ec9db44973e +Create Date: 2026-07-19 12:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "ff43df27a2e0" +down_revision: str | Sequence[str] | None = "0ec9db44973e" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "durable_runs", + sa.Column("answered_parked_at", sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("durable_runs", "answered_parked_at") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index c71d0f9..56eedef 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -479,6 +479,7 @@ def seed_build_run( input_gate: str | None = None, input_request: dict | None = None, failure: str | None = None, + account_id: str | None = None, ): """Seed a build Run row for a work item and bind it via ``item.build_run_id``. Returns the Run. Attach agent calls with @@ -496,6 +497,7 @@ def seed_build_run( input_gate=input_gate, input_request=input_request, failure=failure, + account_id=account_id, ) session.add(run) session.flush() diff --git a/backend/tests/test_agent_routes.py b/backend/tests/test_agent_routes.py new file mode 100644 index 0000000..befe610 --- /dev/null +++ b/backend/tests/test_agent_routes.py @@ -0,0 +1,351 @@ +from datetime import UTC, datetime +from pathlib import Path + +import pytest +from conftest import ( + configure_app_for_test, + make_settings, + make_test_work_item, + seed_build_run, + seed_call, + seed_run, +) +from druks.accounts.models import Account +from druks.build import agent as build_agent +from druks.build.workflows import BuildWorkflow +from druks.durable import agent as durable_agent +from druks.durable.models import Run +from druks.durable.reads import read_transcript_chunk +from druks.usage import agent as usage_agent +from fastapi.testclient import TestClient + +_IN_APP_ASK = { + "presentation": "in_app", + "controls": ["approve", "request_changes", "cancel"], + "questions": [], +} + +_AGENT_ROUTES = { + ("get", "/api/build/agent/work"): "list_work", + ("get", "/api/build/agent/work-items/{work_item_id}"): "get_work_item", + ("post", "/api/build/agent/dispatch"): "dispatch", + ("get", "/api/agent/gates/{run_id}"): "get_gate", + ("post", "/api/agent/gates/{run_id}/answer"): "answer_gate", + ("get", "/api/agent/agent-calls/{call_id}"): "get_agent_call", + ("post", "/api/agent/runs/{run_id}/cancel"): "cancel_run", + ("get", "/api/usage/agent"): "get_usage", +} + + +@pytest.fixture +def client(tmp_path: Path, db_session, monkeypatch): + monkeypatch.setenv("DRUKS_DATA_DIR", str(tmp_path)) + app = configure_app_for_test(settings=make_settings(tmp_path)) + with TestClient(app) as client: + yield client + + +@pytest.fixture +def account(db_session): + # The account configure_app_for_test signs requests in as. + return Account.get_or_create("op@example.com") + + +@pytest.fixture +def resume_spy(monkeypatch): + calls = [] + + async def _spy(self, **fields): + calls.append({"id": self.id, **fields}) + + monkeypatch.setattr(Run, "resume", _spy) + return calls + + +def _park(db_session, item_id): + run = seed_build_run( + db_session, + work_item_id=item_id, + state="pending_input", + input_gate="review", + input_request=dict(_IN_APP_ASK), + ) + run.input_requested_at = datetime.now(UTC) + db_session.flush() + return run + + +def test_openapi_pins_the_eight_agent_routes(client: TestClient): + from druks.api.app import app + + schema = app.openapi() + found = { + (method, path): operation + for path, operations in schema["paths"].items() + for method, operation in operations.items() + if operation.get("tags") == ["agent"] + } + assert {key: op["operationId"] for key, op in found.items()} == _AGENT_ROUTES + + +def test_agent_routes_sit_behind_the_gate(tmp_path, db_session): + app = configure_app_for_test(settings=make_settings(tmp_path), authenticated=False) + with TestClient(app) as anonymous: + assert anonymous.get("/api/build/agent/work").status_code == 401 + assert anonymous.get("/api/agent/gates/x").status_code == 401 + assert anonymous.get("/api/usage/agent").status_code == 401 + + +def test_agent_errors_share_one_shape(client: TestClient, db_session): + missing = client.get("/api/agent/gates/no-such-run") + assert missing.status_code == 404 + assert missing.json() == { + "code": "RUN_NOT_FOUND", + "message": "No run no-such-run.", + "retryable": False, + } + + bad_cursor = client.get("/api/build/agent/work", params={"cursor": "!!junk!!"}) + assert bad_cursor.status_code == 400 + assert bad_cursor.json()["code"] == "INVALID_CURSOR" + + item = make_test_work_item(repo="o/r", title="t") + run = _park(db_session, item.id) + stale = client.post( + f"/api/agent/gates/{run.id}/answer", + json={"parkedAt": "2020-01-01T00:00:00+00:00", "control": "approve"}, + ) + assert stale.status_code == 409 + body = stale.json() + assert body["code"] == "GATE_ROUND_STALE" + assert body["retryable"] is True + + missing_item = client.get("/api/build/agent/work-items/999999") + assert missing_item.status_code == 404 + assert missing_item.json()["code"] == "WORK_ITEM_NOT_FOUND" + + +def test_get_gate_then_answer_roundtrip(client: TestClient, db_session, resume_spy): + item = make_test_work_item(repo="o/r", title="t") + run = _park(db_session, item.id) + + view = client.get(f"/api/agent/gates/{run.id}") + assert view.status_code == 200 + data = view.json() + assert data == durable_agent.get_gate(run.id).model_dump(mode="json", by_alias=True) + + answered = client.post( + f"/api/agent/gates/{run.id}/answer", + json={"parkedAt": data["parkedAt"], "control": "approve", "note": "ship it"}, + ) + assert answered.status_code == 200 + assert answered.json()["result"] == "answered" + assert resume_spy == [{"id": run.id, "action": "approve", "answers": {}, "note": "ship it"}] + + +def test_answer_gate_reads_already_answered_off_the_receipt( + client: TestClient, db_session, resume_spy +): + item = make_test_work_item(repo="o/r", title="t") + parked_at = datetime.now(UTC) + run = seed_build_run(db_session, work_item_id=item.id, state="running") + run.input_requested_at = parked_at + run.answered_parked_at = parked_at + db_session.flush() + + response = client.post( + f"/api/agent/gates/{run.id}/answer", + json={"parkedAt": parked_at.isoformat(), "control": "approve"}, + ) + + assert response.status_code == 200 + assert response.json()["result"] == "already_answered" + assert resume_spy == [] + + +def test_answer_gate_requires_an_aware_parked_at(client: TestClient, db_session): + item = make_test_work_item(repo="o/r", title="t") + run = _park(db_session, item.id) + + naive = client.post( + f"/api/agent/gates/{run.id}/answer", + json={"parkedAt": "2026-07-19T10:00:00", "control": "approve"}, + ) + + assert naive.status_code == 422 # Pydantic's, not the agent taxonomy + + +def test_cancel_run_route(client: TestClient, db_session): + item = make_test_work_item(repo="o/r", title="t") + run = seed_build_run(db_session, work_item_id=item.id, state="running") + # Parked, so the cancel must clear the gate — and never write the receipt. + run.input_gate = "review" + run.input_request = {"presentation": "in_app", "questions": []} + run.input_requested_at = run.utc_now() + db_session.flush() + + unbounded = client.post(f"/api/agent/runs/{run.id}/cancel", json={"reason": "r" * 501}) + assert unbounded.status_code == 422 + blank = client.post(f"/api/agent/runs/{run.id}/cancel", json={"reason": ""}) + assert blank.status_code == 422 + + cancelled = client.post(f"/api/agent/runs/{run.id}/cancel", json={"reason": "wrong branch"}) + assert cancelled.status_code == 200 + assert cancelled.json() == {"runId": run.id, "result": "cancelled"} + + db_session.expire_all() + run = db_session.get(type(run), run.id) + assert not run.answered_parked_at + assert not run.input_gate + assert run.failure == "wrong branch" + + again = client.post(f"/api/agent/runs/{run.id}/cancel", json={"reason": "wrong branch"}) + assert again.status_code == 200 + assert again.json()["result"] == "already_cancelled" + + +def test_dispatch_route(client: TestClient, db_session, account, monkeypatch): + item = make_test_work_item(repo="o/r", title="t") + seed_run(db_session, "run-dispatch", account_id=account.id) + + async def _start(cls, **kwargs): + return "run-dispatch" + + monkeypatch.setattr(BuildWorkflow, "start", classmethod(_start)) + + both = client.post( + "/api/build/agent/dispatch", + json={"work_item_id": item.id, "source": "linear", "ticket_ref": "ACME-1"}, + ) + assert both.status_code == 422 + neither = client.post("/api/build/agent/dispatch", json={}) + assert neither.status_code == 422 + half = client.post("/api/build/agent/dispatch", json={"source": "linear"}) + assert half.status_code == 422 + + ok = client.post("/api/build/agent/dispatch", json={"work_item_id": item.id}) + assert ok.status_code == 200 + body = ok.json() + assert body["workItemId"] == item.id + assert body["runId"] == "run-dispatch" + assert body["isOwnedByCaller"] is True + + +def test_work_routes_match_the_services(client: TestClient, db_session, account): + item = make_test_work_item(repo="o/r", title="parity", remote_key="ACME-9") + run = seed_build_run(db_session, work_item_id=item.id, state="running") + seed_call(db_session, run, "implement") + + page = client.get("/api/build/agent/work") + assert page.status_code == 200 + assert page.json() == build_agent.list_work(account).model_dump(mode="json", by_alias=True) + + detail = client.get(f"/api/build/agent/work-items/{item.id}") + assert detail.status_code == 200 + assert detail.json() == build_agent.get_work_item(item.id).model_dump( + mode="json", by_alias=True + ) + + call_id = detail.json()["runs"][0]["agentCalls"][0]["id"] + call = client.get(f"/api/agent/agent-calls/{call_id}") + assert call.status_code == 200 + assert call.json() == durable_agent.get_agent_call(call_id).model_dump( + mode="json", by_alias=True + ) + + +def test_board_status_matches_list_work(client: TestClient, db_session, account): + item = make_test_work_item(repo="o/r", title="board parity") + _park(db_session, item.id) + + board = client.get("/api/build/work_item") + assert board.status_code == 200 + row = next(r for r in board.json()["rows"] if r["summary"]["id"] == str(item.id)) + listed = next( + i + for i in build_agent.list_work(account).model_dump(mode="json", by_alias=True)["items"] + if i["workItemId"] == item.id + ) + assert listed["status"] == row["status"] + + +def test_transcript_route_matches_the_read_machinery(client: TestClient, db_session, db_engine): + from conftest import seed_agent_run + + call = seed_agent_run() + call_dir = call.call_dir + call_dir.mkdir(parents=True, exist_ok=True) + (call_dir / "stdout.jsonl").write_bytes(b"hello " + "é".encode() + b" transcript") + + response = client.get( + f"/api/build/transcripts/{call.id}", params={"stream": "stdout", "limit": 7} + ) + assert response.status_code == 200 + chunk = read_transcript_chunk(db_engine, call.id, "stdout", offset=0, limit=7) + assert response.json() == chunk.model_dump(mode="json", by_alias=True) + # The 7-byte cut lands mid-é; the served chunk ends on a character boundary. + assert response.json()["text"] == "hello " + + +def test_resume_route_contract_is_preserved(client: TestClient, db_session, resume_spy): + unknown = client.post("/api/runs/no-such-run/resume", json={"control": "approve"}) + assert unknown.status_code == 404 + + item = make_test_work_item(repo="o/r", title="t") + idle = seed_build_run(db_session, work_item_id=item.id, state="running") + not_waiting = client.post(f"/api/runs/{idle.id}/resume", json={"control": "approve"}) + assert not_waiting.status_code == 409 + + parked_item = make_test_work_item(repo="o/r2", title="t") + run = _park(db_session, parked_item.id) + bad_control = client.post(f"/api/runs/{run.id}/resume", json={"control": "merge"}) + assert bad_control.status_code == 422 + assert resume_spy == [] + + ok = client.post( + f"/api/runs/{run.id}/resume", + json={"control": "approve", "answers": {}, "note": "go"}, + ) + assert ok.status_code == 204 + assert resume_spy == [{"id": run.id, "action": "approve", "answers": {}, "note": "go"}] + + # Once the answer has landed (receipt written, gate cleared), the + # dashboard's double-submit stays the conflict it has always been. + run.answered_parked_at = run.input_requested_at + run.input_gate = None + run.input_request = None + db_session.flush() + late = client.post(f"/api/runs/{run.id}/resume", json={"control": "approve"}) + assert late.status_code == 409 + assert len(resume_spy) == 1 + + +def test_usage_agent_route_matches_the_service(client: TestClient, db_session, account): + from druks.durable.models import AgentCall + + run = seed_run(db_session, "run-usage-route") + db_session.add( + AgentCall( + run_id=run.id, + account_id=account.id, + sandbox_host_id="host", + model="gpt-5.5", + status="succeeded", + finished_at=datetime.now(UTC), + cost_usd=1.25, + cost_metadata={"total_tokens": 500}, + ) + ) + db_session.flush() + + response = client.get("/api/usage/agent") + assert response.status_code == 200 + body = response.json() + assert body == usage_agent.get_usage(account).model_dump(mode="json", by_alias=True) + assert len(response.content) <= 4 * 1024 + + today = client.get("/api/usage/today").json() + assert sum(h["spendUsd"] for h in today["harnesses"]) == pytest.approx(body["spendTodayUsd"]) + assert sum(h["runs"] for h in today["harnesses"]) == body["runsToday"] + assert sum(h["tokens"] for h in today["harnesses"]) == body["tokensToday"] + assert today["day"] == body["day"] diff --git a/backend/tests/test_agent_services.py b/backend/tests/test_agent_services.py new file mode 100644 index 0000000..15714b4 --- /dev/null +++ b/backend/tests/test_agent_services.py @@ -0,0 +1,634 @@ +import json +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from conftest import make_test_work_item, seed_build_run, seed_call +from druks.accounts.models import Account +from druks.build import agent as build_agent +from druks.build.exceptions import InvalidCursor, WorkItemNotFound +from druks.build.workflows import BuildWorkflow +from druks.durable import agent as durable_agent +from druks.durable.exceptions import ( + AgentCallNotFound, + GateNotAnswerable, + GateNotOpen, + GateRoundStale, + InvalidGateAnswer, + RunNotActive, + RunNotFound, +) +from druks.durable.models import Artifact, Run +from druks.durable.reads import read_slice +from druks.usage import agent as usage_agent +from druks.usage.models import UsageScrape + +pytestmark = pytest.mark.usefixtures("_data_dir") + + +@pytest.fixture +def _data_dir(tmp_path, monkeypatch): + # AgentCall.call_dir derives from load_settings().artifacts_dir. + monkeypatch.setenv("DRUKS_DATA_DIR", str(tmp_path)) + + +@pytest.fixture +def account(db_session): + return Account.get_or_create("op@example.com") + + +@pytest.fixture +def resume_spy(monkeypatch): + calls = [] + + async def _spy(self, **fields): + calls.append({"id": self.id, **fields}) + + monkeypatch.setattr(Run, "resume", _spy) + return calls + + +def _in_app_ask(questions=()): + return { + "presentation": "in_app", + "controls": ["approve", "request_changes", "cancel"], + "questions": list(questions), + } + + +def _park(db_session, item_id, *, ask=None): + run = seed_build_run( + db_session, + work_item_id=item_id, + state="pending_input", + input_gate="review", + input_request=ask if ask is not None else _in_app_ask(), + ) + run.input_requested_at = datetime.now(UTC) + db_session.flush() + return run + + +# ---- read_slice ----------------------------------------------------------- + + +def test_read_slice_tail_snaps_a_split_character(tmp_path: Path): + path = tmp_path / "log.txt" + path.write_bytes(b"abcd" + "é".encode() + b"wxyz") + + piece = read_slice(path, offset=-5, limit=5) + + # Byte 5 is the é's continuation byte; the slice starts after it. + assert piece.offset == 6 + assert piece.text == "wxyz" + assert piece.has_earlier is True + assert piece.eof is True + + +def test_read_slice_head_trims_a_trailing_partial_character(tmp_path: Path): + path = tmp_path / "log.txt" + path.write_bytes(b"abcd" + "é".encode() + b"wxyz") + + head = read_slice(path, offset=0, limit=5) + assert head.text == "abcd" + assert head.next_offset == 4 + assert head.eof is False + assert head.has_earlier is False + + rest = read_slice(path, offset=head.next_offset, limit=100) + assert rest.text == "éwxyz" + assert rest.eof is True + + +def test_read_slice_trims_a_partial_four_byte_character(tmp_path: Path): + path = tmp_path / "log.txt" + payload = "ab🎉cd".encode() + path.write_bytes(payload) + + piece = read_slice(path, offset=0, limit=4) # cuts the emoji after 2 of 4 bytes + + assert piece.text == "ab" + assert piece.next_offset == 2 + + +def test_read_slice_tiny_limit_still_progresses(tmp_path: Path): + path = tmp_path / "log.txt" + path.write_bytes("🎉x".encode()) + + # A limit smaller than one character can never advance; the floor of one + # whole character keeps pagination moving. + piece = read_slice(path, offset=0, limit=1) + assert piece.text == "🎉" + assert piece.next_offset == 4 + + rest = read_slice(path, offset=piece.next_offset, limit=1) + assert rest.text == "x" + assert rest.eof is True + + +def test_read_slice_invalid_bytes_still_progress(tmp_path: Path): + # Raw terminal output isn't guaranteed UTF-8: an invalid byte can never + # complete into a character, so it must be replaced and passed, not + # trimmed forever. + path = tmp_path / "log.txt" + path.write_bytes(b"ok\xff") + + piece = read_slice(path, offset=0, limit=100) + assert piece.text == "ok�" + assert piece.next_offset == 3 + assert piece.eof is True + + # A surrogate prefix (ED A0) has a valid lead but can never complete; + # trimming it would stall the reader at offset 2 forever. + path.write_bytes(b"ok\xed\xa0") + stuck = read_slice(path, offset=0, limit=100) + assert stuck.text[:2] == "ok" + assert stuck.next_offset == 4 + assert stuck.eof is True + + +def test_read_slice_live_tail_re_covers_a_mid_write_character(tmp_path: Path): + path = tmp_path / "live.txt" + path.write_bytes(b"ab" + "🎉".encode()[:2]) # the writer is mid-emoji + + piece = read_slice(path, offset=0, limit=100) + assert piece.text == "ab" + assert piece.next_offset == 2 + assert piece.eof is False # the character isn't whole yet + + path.write_bytes(b"ab" + "🎉".encode()) + rest = read_slice(path, offset=piece.next_offset, limit=100) + assert rest.text == "🎉" + assert rest.eof is True + + +def test_read_slice_missing_file_is_an_empty_eof(tmp_path: Path): + piece = read_slice(tmp_path / "absent.txt", offset=-1024, limit=1024) + assert piece.text == "" + assert piece.eof is True + assert piece.has_earlier is False + + +# ---- gates ---------------------------------------------------------------- + + +def test_get_gate_returns_ask_schema_and_parked_at(db_session): + item = make_test_work_item(repo="o/r", title="t") + question = {"id": "q1", "prompt": "Which db?", "options": [{"id": "pg", "label": "Postgres"}]} + run = _park(db_session, item.id, ask=_in_app_ask([question])) + + view = durable_agent.get_gate(run.id) + + assert view.run_id == run.id + assert view.gate == "review" + assert view.parked_at == run.input_requested_at + assert view.ask["controls"] == ["approve", "request_changes", "cancel"] + schema = view.reply_schema + assert schema["properties"]["control"]["enum"] == ["approve", "request_changes", "cancel"] + assert schema["properties"]["answers"]["properties"]["q1"]["description"] == "Which db?" + assert schema["required"] == ["control"] + + +def test_get_gate_bounds_an_agent_authored_ask(db_session): + item = make_test_work_item(repo="o/r", title="t") + question = { + "id": "q1", + "prompt": "🦖" * 5000, + "options": [{"id": "a", "label": "💥" * 500}], + } + run = _park(db_session, item.id, ask=_in_app_ask([question])) + + view = durable_agent.get_gate(run.id) + + prompt = view.ask["questions"][0]["prompt"] + label = view.ask["questions"][0]["options"][0]["label"] + assert len(json.dumps(prompt, ensure_ascii=False).encode()) - 2 <= 2048 + assert len(json.dumps(label, ensure_ascii=False).encode()) - 2 <= 256 + # The reply schema derives from the bounded ask, so its description is the + # clipped prompt — one bounded view, both structures. + assert view.reply_schema["properties"]["answers"]["properties"]["q1"]["description"] == prompt + + +def test_get_gate_serves_a_bounded_artifact_chunk(db_session): + item = make_test_work_item(repo="o/r", title="t") + run = _park(db_session, item.id) + call = seed_call(db_session, run, "generate_plan") + Artifact.record( + call_dir=call.call_dir, call_id=call.id, kind="markdown", title="Plan", content="x" * 10240 + ) + + view = durable_agent.get_gate(run.id) + + assert view.artifact is not None + assert view.artifact.call_id == call.id + assert view.artifact.title == "Plan" + assert len(view.artifact.chunk.text.encode()) <= 4096 + assert view.artifact.chunk.eof is False + + +def test_get_gate_refuses_when_not_parked_or_external(db_session): + with pytest.raises(RunNotFound): + durable_agent.get_gate("no-such-run") + + item = make_test_work_item(repo="o/r", title="t") + running = seed_build_run(db_session, work_item_id=item.id, state="running") + with pytest.raises(GateNotOpen): + durable_agent.get_gate(running.id) + + external_item = make_test_work_item(repo="o/r2", title="t") + external = _park( + db_session, + external_item.id, + ask={"presentation": "external", "label": "Answer on the ticket"}, + ) + with pytest.raises(GateNotAnswerable): + durable_agent.get_gate(external.id) + + +async def test_answer_gate_resumes_through_run_resume(db_session, resume_spy): + item = make_test_work_item(repo="o/r", title="t") + run = _park(db_session, item.id) + + result = await durable_agent.answer_gate( + run.id, + parked_at=run.input_requested_at, + control="approve", + answers={}, + note="ship it", + ) + + assert result.result == "answered" + assert result.parked_at == run.input_requested_at + assert resume_spy == [{"id": run.id, "action": "approve", "answers": {}, "note": "ship it"}] + + +async def test_answer_gate_uses_the_receipt_for_already_answered(db_session, resume_spy): + item = make_test_work_item(repo="o/r", title="t") + parked_at = datetime.now(UTC) + run = seed_build_run(db_session, work_item_id=item.id, state="running") + run.input_requested_at = parked_at + run.answered_parked_at = parked_at + db_session.flush() + + result = await durable_agent.answer_gate( + run.id, parked_at=parked_at, control="approve", answers={}, note="" + ) + + assert result.result == "already_answered" + assert resume_spy == [] + + +async def test_answer_gate_error_taxonomy(db_session, resume_spy): + with pytest.raises(RunNotFound): + await durable_agent.answer_gate( + "no-such-run", parked_at=datetime.now(UTC), control="approve", answers={}, note="" + ) + + item = make_test_work_item(repo="o/r", title="t") + finished = seed_build_run(db_session, work_item_id=item.id, state="finished") + with pytest.raises(GateNotOpen): + await durable_agent.answer_gate( + finished.id, parked_at=datetime.now(UTC), control="approve", answers={}, note="" + ) + + parked_item = make_test_work_item(repo="o/r2", title="t") + run = _park(db_session, parked_item.id) + with pytest.raises(GateRoundStale): + await durable_agent.answer_gate( + run.id, + parked_at=run.input_requested_at - timedelta(seconds=5), + control="approve", + answers={}, + note="", + ) + with pytest.raises(InvalidGateAnswer): + await durable_agent.answer_gate( + run.id, parked_at=run.input_requested_at, control="merge", answers={}, note="" + ) + with pytest.raises(InvalidGateAnswer): + await durable_agent.answer_gate( + run.id, + parked_at=run.input_requested_at, + control="approve", + answers={}, + note="n" * 2049, + ) + + external_item = make_test_work_item(repo="o/r3", title="t") + external = _park( + db_session, + external_item.id, + ask={"presentation": "external", "label": "Answer on the ticket"}, + ) + with pytest.raises(GateNotAnswerable): + await durable_agent.answer_gate( + external.id, + parked_at=external.input_requested_at, + control="approve", + answers={}, + note="", + ) + assert resume_spy == [] + + +# ---- agent calls ---------------------------------------------------------- + + +def test_get_agent_call_serves_bounded_tails(db_session): + from conftest import finish_agent_run, seed_agent_run + + call = seed_agent_run() + call_dir = call.call_dir + call_dir.mkdir(parents=True, exist_ok=True) + (call_dir / "stdout.jsonl").write_bytes(b"s" * 20480) + (call_dir / "stderr.log").write_bytes(b"e" * 10240) + finish_agent_run(call, last_error="boom " * 100) + Artifact.record( + call_dir=call_dir, call_id=call.id, kind="markdown", title="Out", content="a" * 10240 + ) + + detail = durable_agent.get_agent_call(call.id) + + assert detail.run_id == call.run_id + assert detail.call.id == call.id + assert len(detail.call.last_error or "") <= 160 + assert len(detail.transcript.text.encode()) <= 8192 + assert detail.transcript.offset == 20480 - 8192 + assert detail.transcript.eof is True + assert detail.transcript.has_earlier is True + assert len(detail.stderr.text.encode()) <= 4096 + assert detail.stderr.has_earlier is True + assert detail.artifact is not None + assert len(detail.artifact.chunk.text.encode()) <= 4096 + + with pytest.raises(AgentCallNotFound): + durable_agent.get_agent_call("no-such-call") + + +def test_get_agent_call_without_files_reads_empty(db_session): + from conftest import seed_agent_run + + call = seed_agent_run() + + detail = durable_agent.get_agent_call(call.id) + + assert detail.transcript.text == "" + assert detail.transcript.eof is True + assert detail.stderr.has_earlier is False + assert detail.artifact is None + + +# ---- cancel --------------------------------------------------------------- + + +async def test_cancel_run_paths(db_session): + item = make_test_work_item(repo="o/r", title="t") + run = seed_build_run(db_session, work_item_id=item.id, state="running") + + result = await durable_agent.cancel_run(run.id, reason="stuck") + assert result.result == "cancelled" + db_session.expire_all() + assert Run.get(run.id).state == "cancelled" + assert Run.get(run.id).failure == "stuck" + + again = await durable_agent.cancel_run(run.id, reason="stuck") + assert again.result == "already_cancelled" + + finished_item = make_test_work_item(repo="o/r2", title="t") + finished = seed_build_run(db_session, work_item_id=finished_item.id, state="finished") + with pytest.raises(RunNotActive): + await durable_agent.cancel_run(finished.id, reason="late") + + with pytest.raises(RunNotFound): + await durable_agent.cancel_run("no-such-run", reason="x") + + +# ---- work board ----------------------------------------------------------- + + +def test_list_work_filters(db_session, account): + mine_item = make_test_work_item(repo="o/mine", title="mine") + mine_run = seed_build_run(db_session, work_item_id=mine_item.id, state="running") + mine_run.account_id = account.id + parked_item = make_test_work_item(repo="o/parked", title="parked") + _park(db_session, parked_item.id) + failed_item = make_test_work_item(repo="o/failed", title="failed") + seed_build_run(db_session, work_item_id=failed_item.id, state="failed", failure="crash") + db_session.flush() + + assert {i.work_item_id for i in build_agent.list_work(account).items} == { + mine_item.id, + parked_item.id, + failed_item.id, + } + mine = build_agent.list_work(account, filter="mine").items + assert [i.work_item_id for i in mine] == [mine_item.id] + parked = build_agent.list_work(account, filter="parked").items + assert [i.work_item_id for i in parked] == [parked_item.id] + assert parked[0].status.state.value == "pending_input" + assert parked[0].status.gate == "review" + active = build_agent.list_work(account, filter="active").items + assert {i.work_item_id for i in active} == {mine_item.id, parked_item.id} + failed = build_agent.list_work(account, filter="failed").items + assert [i.work_item_id for i in failed] == [failed_item.id] + assert failed[0].status.failure == "crash" + + +def test_list_work_walks_the_keyset_cursor(db_session, account): + for index in range(20): + make_test_work_item(repo=f"o/r{index}", title=f"item {index}") + + first = build_agent.list_work(account) + assert len(first.items) == 12 + assert first.next_cursor + second = build_agent.list_work(account, cursor=first.next_cursor) + assert len(second.items) == 8 + assert second.next_cursor is None + seen = [item.work_item_id for item in [*first.items, *second.items]] + assert len(seen) == len(set(seen)) == 20 + + with pytest.raises(InvalidCursor): + build_agent.list_work(account, cursor="!!not-a-cursor!!") + + +def test_list_work_page_stays_within_budget(db_session, account): + # The budgets bound the SERIALIZED response, so the worst-case page must + # hold for maximally multibyte text and for control bytes that JSON + # escaping blows up six-fold (ANSI color codes in real failures). + for index in range(13): + item = make_test_work_item( + repo=f"o/very-long-repo-name-{index}", + title="🦖" * 400, + remote_url="https://tracker.example.com/" + "x" * 1000, + ) + seed_build_run(db_session, work_item_id=item.id, state="failed", failure="\x1b[31m💥" * 500) + + page = build_agent.list_work(account) + + assert len(page.items) == 12 + for row in page.items: + assert len(json.dumps(row.title, ensure_ascii=False).encode()) - 2 <= 120 + assert len(json.dumps(row.status.failure or "", ensure_ascii=False).encode()) - 2 <= 160 + assert len(page.model_dump_json(by_alias=True).encode()) <= 12 * 1024 + + +def test_get_work_item_detail_stays_within_budget(db_session, account): + item = make_test_work_item( + repo="o/r", + title="🦖" * 400, + remote_key="ACME-1", + remote_url="https://tracker.example.com/" + "x" * 17000, + ) + for _ in range(6): + run = seed_build_run(db_session, work_item_id=item.id, state="finished") + for _ in range(6): + seed_call(db_session, run, "implement", status="failed", last_error="💥" * 1000) + seed_build_run(db_session, work_item_id=item.id, state="failed", failure="💥" * 1000) + + detail = build_agent.get_work_item(item.id) + + assert detail.work_item_id == item.id + assert len(detail.title.encode()) <= 120 + assert len(detail.runs) == 5 + for run_summary in detail.runs: + assert len(run_summary.agent_calls) <= 5 + for call_summary in run_summary.agent_calls: + assert len((call_summary.last_error or "").encode()) <= 160 + assert detail.links.repo == "https://github.com/o/r" + assert len(detail.model_dump_json(by_alias=True).encode()) <= 16 * 1024 + + with pytest.raises(WorkItemNotFound): + build_agent.get_work_item(999999) + + +# ---- dispatch ------------------------------------------------------------- + + +@pytest.fixture +def start_stub(db_session, monkeypatch): + from conftest import seed_run + + async def _start(cls, **kwargs): + if not Run.get("run-dispatch"): + seed_run(db_session, "run-dispatch") + return "run-dispatch" + + monkeypatch.setattr(BuildWorkflow, "start", classmethod(_start)) + + +async def test_dispatch_by_work_item_id(db_session, account, start_stub): + item = make_test_work_item(repo="o/r", title="t") + + result = await build_agent.dispatch(account, work_item_id=item.id) + + assert result.work_item_id == item.id + assert result.run_id == "run-dispatch" + run_row = Run.get("run-dispatch") + assert result.is_owned_by_caller == (run_row.account_id == account.id) + assert item.build_run_id == "run-dispatch" + assert item.status is None + + +async def test_dispatch_by_ticket_ref(db_session, account, start_stub): + item = make_test_work_item(repo="o/r", title="t", source="linear", remote_key="ACME-7") + + result = await build_agent.dispatch(account, source="linear", ticket_ref="ACME-7") + + assert result.work_item_id == item.id + assert result.run_id == "run-dispatch" + + +async def test_dispatch_stamps_ambient_attribution(db_session, account, start_stub): + # The MCP boundary has no session gate, so the service itself must stamp + # the caller before the launch policy runs — start() inherits it when the + # item carries no assignee. + from druks.accounts import sessions + + item = make_test_work_item(repo="o/r", title="t") + token = sessions.current_account_id.set(None) + try: + await build_agent.dispatch(account, work_item_id=item.id) + assert sessions.current_account_id.get() == account.id + finally: + sessions.current_account_id.reset(token) + + +async def test_dispatch_unknown_item(db_session, account, start_stub): + with pytest.raises(WorkItemNotFound): + await build_agent.dispatch(account, work_item_id=424242) + with pytest.raises(WorkItemNotFound): + await build_agent.dispatch(account, source="jira", ticket_ref="NOPE-1") + + +# ---- usage ---------------------------------------------------------------- + + +def test_get_usage_is_a_bounded_pure_read(db_session, account): + from conftest import seed_run + from druks.durable.models import AgentCall + + now = datetime.now(UTC) + run = seed_run(db_session, "run-usage") + for index in range(30): + db_session.add( + AgentCall( + run_id=run.id, + account_id=account.id, + sandbox_host_id="host", + model="gpt-5.5", + status="succeeded", + finished_at=now, + cost_usd=0.5, + cost_metadata={"input_tokens": 100 + index}, + ) + ) + for tick in range(40): + UsageScrape( + harness="claude", + account_id=account.id, + scraped_at=now - timedelta(minutes=5 * tick), + plan_tier="max", + five_hour_percent_left=90 - tick, + five_hour_resets_at=now + timedelta(hours=2), + week_percent_left=80 - tick, + week_resets_at=now + timedelta(days=3), + ).save() + + usage = usage_agent.get_usage(account) + + assert usage.runs_today == 30 + assert usage.spend_today_usd == pytest.approx(15.0) + assert usage.tokens_today == sum(100 + i for i in range(30)) + claude = next(h for h in usage.harnesses if h.name == "claude") + assert claude.plan_tier == "max" + assert claude.five_hour_percent_left == 90 + assert len(claude.five_hour_history) <= 8 + assert len(claude.week_history) <= 8 + # The newest sample anchors each trend. + assert claude.week_history[-1].pct == 80 + assert len(usage.model_dump_json(by_alias=True).encode()) <= 4 * 1024 + + +def test_get_usage_only_counts_the_callers_spend(db_session, account): + from conftest import seed_run + from druks.durable.models import AgentCall + + other = Account.get_or_create("other@example.com") + run = seed_run(db_session, "run-usage-other") + db_session.add( + AgentCall( + run_id=run.id, + account_id=other.id, + sandbox_host_id="host", + model="gpt-5.5", + status="succeeded", + finished_at=datetime.now(UTC), + cost_usd=9.0, + ) + ) + db_session.flush() + + usage = usage_agent.get_usage(account) + + assert usage.runs_today == 0 + assert usage.spend_today_usd == 0.0 diff --git a/backend/tests/test_contracts.py b/backend/tests/test_contracts.py index 458b2a2..53a80de 100644 --- a/backend/tests/test_contracts.py +++ b/backend/tests/test_contracts.py @@ -4,6 +4,7 @@ # real schema, so this guards it directly. import pytest from druks.build import contracts as O +from pydantic import ValidationError MODELS = [ O.PlanOutput, @@ -111,3 +112,21 @@ def test_get_answered_maps_picks_to_labels_and_keeps_free_text_verbatim(): {"question": "Which cache?", "answer": "Redis"}, {"question": "Which queue?", "answer": "kafka — we already run it"}, ] + + +def test_ask_contracts_cap_identity_and_cardinality(): + # The gate view is bounded by construction: identity and list sizes are + # hard caps at the agent boundary, never clipped downstream. + option = O.QuestionOptionOutput(id="a", label="Redis") + with pytest.raises(ValidationError): + O.QuestionOptionOutput(id="a" * 65, label="Redis") + with pytest.raises(ValidationError): + O.QuestionOutput(id="q", prompt="p" * 2049, options=[]) + with pytest.raises(ValidationError): + O.QuestionOutput(id="q", prompt="p", options=[option] * 17) + with pytest.raises(ValidationError): + O.PlanOutput( + plan_markdown="m", + acceptance_criteria=[], + questions=[O.QuestionOutput(id=f"q{i}", prompt="p", options=[]) for i in range(9)], + ) diff --git a/backend/tests/test_gate_receipt.py b/backend/tests/test_gate_receipt.py new file mode 100644 index 0000000..afec305 --- /dev/null +++ b/backend/tests/test_gate_receipt.py @@ -0,0 +1,86 @@ +import pytest +from conftest import seed_run +from dbos import DBOS +from dbos._error import DBOSWorkflowCancelledError +from druks.durable.exceptions import GateTimeout +from druks.durable.models import Run +from druks.workflows import _park + +_ASK = {"presentation": "in_app", "controls": ["approve"], "questions": []} + + +class _ParkedWorkflow: + # The slice of Workflow that _park touches. subject=None keeps the emit to + # its facts write (no feed event, no notification) — the receipt path under + # test is exactly that write. + def __init__(self, workflow_id: str) -> None: + self.workflow_id = workflow_id + self.subject = None + + async def _reap_run(self) -> None: + return + + +@pytest.fixture +def _direct_steps(monkeypatch): + # Run each durable step body inline — the test exercises _park's own logic, + # not DBOS checkpointing. + async def _call_through(options, func, *args, **kwargs): + return await func(*args, **kwargs) + + monkeypatch.setattr(DBOS, "run_step_async", _call_through) + + +def _reload(db_session, run_id: str) -> Run: + db_session.expire_all() + return db_session.get(Run, run_id) + + +async def test_answer_stamps_the_receipt_beside_the_gate_clear( + db_session, _direct_steps, monkeypatch +): + run = seed_run(db_session, "run-receipt-answer") + + async def _answer(topic, timeout_seconds): + return {"action": "approve"} + + monkeypatch.setattr(DBOS, "recv_async", _answer) + payload = await _park(_ParkedWorkflow(run.id), "review", _ASK, ttl_seconds=1.0) + + assert payload == {"action": "approve"} + run = _reload(db_session, run.id) + # The receipt is the round the answer cleared: the same stamp the park + # wrote, which _GATE_CLEARED preserves on the row. + assert run.input_requested_at + assert run.answered_parked_at == run.input_requested_at + assert not run.input_gate + assert not run.input_request + + +async def test_timeout_never_writes_the_receipt(db_session, _direct_steps, monkeypatch): + run = seed_run(db_session, "run-receipt-timeout") + + async def _lapse(topic, timeout_seconds): + return None + + monkeypatch.setattr(DBOS, "recv_async", _lapse) + with pytest.raises(GateTimeout): + await _park(_ParkedWorkflow(run.id), "review", _ASK, ttl_seconds=1.0) + + run = _reload(db_session, run.id) + assert not run.answered_parked_at + assert run.input_requested_at + + +async def test_cancel_never_writes_the_receipt(db_session, _direct_steps, monkeypatch): + run = seed_run(db_session, "run-receipt-cancel") + + async def _cancelled(topic, timeout_seconds): + raise DBOSWorkflowCancelledError(run.id) + + monkeypatch.setattr(DBOS, "recv_async", _cancelled) + with pytest.raises(DBOSWorkflowCancelledError): + await _park(_ParkedWorkflow(run.id), "review", _ASK, ttl_seconds=1.0) + + run = _reload(db_session, run.id) + assert not run.answered_parked_at diff --git a/backend/tests/test_resume.py b/backend/tests/test_resume.py index 2da9384..4ac7acb 100644 --- a/backend/tests/test_resume.py +++ b/backend/tests/test_resume.py @@ -5,14 +5,25 @@ from druks.durable import Run from fastapi import HTTPException +# The shape review() parks with: an in-app ask plus the parked_at stamp the +# answer service echoes. _ASK = { + "presentation": "in_app", "controls": ["approve", "request_changes", "cancel"], "questions": [{"id": "q1", "prompt": "which?", "options": [{"id": "a", "label": "A"}]}], } def _park(db_session) -> None: - db_session.add(Run(id="r1", kind="build", input_gate="review_plan", input_request=_ASK)) + db_session.add( + Run( + id="r1", + kind="build", + input_gate="review_plan", + input_request=_ASK, + input_requested_at=Run.utc_now(), + ) + ) db_session.flush() seed_dbos_status(db_session, "r1", "pending_input") From 90443025efaeb586a2fe70588778cd7e1eddeae2 Mon Sep 17 00:00:00 2001 From: Paulo Date: Sun, 19 Jul 2026 19:21:54 +0200 Subject: [PATCH 02/21] Move clip to druks/schemas and extract usage read helpers clip and _wire_cost are generic response-field budget helpers shared by durable, usage, and the agent surface, so they move to the neutral druks/schemas module beside BaseResponse. The usage operator-local-day read and the trend downsampler move to a new usage/reads module that both the dashboard routes and the usage agent service read through. --- backend/druks/durable/schemas.py | 26 +------------ backend/druks/schemas.py | 25 ++++++++++++ backend/druks/usage/agent.py | 66 +++++--------------------------- backend/druks/usage/reads.py | 58 ++++++++++++++++++++++++++++ backend/druks/usage/routes.py | 2 +- 5 files changed, 95 insertions(+), 82 deletions(-) create mode 100644 backend/druks/usage/reads.py diff --git a/backend/druks/durable/schemas.py b/backend/druks/durable/schemas.py index f817d2b..4de31ae 100644 --- a/backend/druks/durable/schemas.py +++ b/backend/druks/durable/schemas.py @@ -1,4 +1,3 @@ -import json from datetime import UTC, datetime from pathlib import Path from typing import Any, Literal @@ -6,7 +5,7 @@ from pydantic import Field, SerializeAsAny from druks.harnesses.artifacts import normalize_token_usage -from druks.schemas import BaseResponse +from druks.schemas import BaseResponse, clip from .enums import AgentCallStatus, RunState from .models import AgentCall, Artifact, Run @@ -31,29 +30,6 @@ def get_display_label(kind: str) -> str: return kind.rsplit(".", 1)[-1].replace("_", " ").capitalize() -def _wire_cost(char: str) -> int: - # What one character spends of a wire budget: its JSON-escaped UTF-8 bytes - # (a control byte like ESC serializes as  — six bytes, not one). - return len(json.dumps(char, ensure_ascii=False).encode()) - 2 - - -def clip(text: str | None, limit: int) -> str | None: - # Budgeted read-sides bound their free-text fields by what the field will - # occupy in the serialized response, so the budgets hold for multibyte and - # escape-heavy text alike. The ellipsis (3 bytes) marks the cut. - if not text: - return text - total = 0 - cut = None - for index, char in enumerate(text): - total += _wire_cost(char) - if cut is None and total > limit - 3: - cut = index - if total > limit: - return text[:cut] + "…" - return text - - def _derived_status(call: AgentCall) -> str: # Liveness is derived, not stored: an unfinished call reads "running" while its # run is active and "abandoned" once the run is terminal (the run ended without diff --git a/backend/druks/schemas.py b/backend/druks/schemas.py index f34d874..c8058fe 100644 --- a/backend/druks/schemas.py +++ b/backend/druks/schemas.py @@ -1,3 +1,5 @@ +import json + from pydantic import BaseModel, ConfigDict from pydantic.alias_generators import to_camel @@ -9,4 +11,27 @@ class BaseResponse(BaseModel): model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) +def _wire_cost(char: str) -> int: + # What one character spends of a wire budget: its JSON-escaped UTF-8 bytes + # (a control byte like ESC serializes as  — six bytes, not one). + return len(json.dumps(char, ensure_ascii=False).encode()) - 2 + + +def clip(text: str | None, limit: int) -> str | None: + # Budgeted read-sides bound their free-text fields by what the field will + # occupy in the serialized response, so the budgets hold for multibyte and + # escape-heavy text alike. The ellipsis (3 bytes) marks the cut. + if not text: + return text + total = 0 + cut = None + for index, char in enumerate(text): + total += _wire_cost(char) + if cut is None and total > limit - 3: + cut = index + if total > limit: + return text[:cut] + "…" + return text + + __all__ = ["BaseResponse"] diff --git a/backend/druks/usage/agent.py b/backend/druks/usage/agent.py index 4285d51..72d6d5b 100644 --- a/backend/druks/usage/agent.py +++ b/backend/druks/usage/agent.py @@ -1,32 +1,21 @@ # Usage's half of the agent surface: one pure read of the caller's quota and -# today's spend — no poll trigger; refresh stays route-driven. The dashboard -# routes share the day-window read and the downsampler declared here. -from datetime import UTC, datetime, timedelta -from zoneinfo import ZoneInfo - -from sqlalchemy import Row, select +# today's spend — no poll trigger; refresh stays route-driven. +from datetime import UTC, datetime from druks.accounts.models import Account -from druks.core.utils.time import operator_local_day -from druks.database import db_session -from druks.durable.models import AgentCall -from druks.durable.schemas import clip from druks.harnesses.artifacts import normalize_token_usage from druks.harnesses.models import HarnessConnection from druks.harnesses.registry import get_harnesses +from druks.schemas import clip from druks.usage.models import UsageScrape +from druks.usage.reads import ( + _HISTORY_POINTS, + FIVE_HOUR_RANGE, + WEEK_RANGE, + downsample, + list_finished_calls_today, +) from druks.usage.schemas import AgentHarnessUsage, AgentUsage, UsageHistoryPoint -from druks.user_settings.models import UserSettings - -# Trend ranges for the percent-left sparklines. The 5h window gets one full -# window plus headroom so an exhaustion arc is visible end to end; weekly gets -# the whole week. -FIVE_HOUR_RANGE = timedelta(hours=6) -WEEK_RANGE = timedelta(days=7) - -# The agent read keeps each trend this short so the whole response stays -# within its byte budget. -_HISTORY_POINTS = 8 def get_usage(account: Account) -> AgentUsage: @@ -50,41 +39,6 @@ def get_usage(account: Account) -> AgentUsage: ) -def list_finished_calls_today(account_id: str) -> tuple[ZoneInfo, datetime, list[Row]]: - # The account's finished calls in the operator-local day, as (timezone, - # local_start, rows of (model, cost_usd, cost_metadata, finished_at)) — - # the shared boundary keeps every spend-today figure identical. - timezone, local_start = operator_local_day(UserSettings.get().timezone, datetime.now(UTC)) - rows = ( - db_session() - .execute( - select( - AgentCall.model, - AgentCall.cost_usd, - AgentCall.cost_metadata, - AgentCall.finished_at, - ) - .where(AgentCall.account_id == account_id) - .where(AgentCall.finished_at.is_not(None)) - .where(AgentCall.finished_at >= local_start.astimezone(UTC)) - .where(AgentCall.finished_at < (local_start + timedelta(days=1)).astimezone(UTC)), - ) - .all() - ) - return timezone, local_start, rows - - -def downsample(points: list[UsageHistoryPoint], *, cap: int) -> list[UsageHistoryPoint]: - # Thin a series to ≤ cap points, always keeping the newest sample (the - # "now" anchor) — it replaces the last strided sample so the cap holds. - if len(points) <= cap: - return points - stride = -(-len(points) // cap) # ceil division - thinned = points[::stride] - thinned[-1] = points[-1] - return thinned - - def _harness_usage(name: str, account_id: str, *, now: datetime) -> AgentHarnessUsage: is_connected = bool(HarnessConnection.get_for_account(name, account_id)) row = UsageScrape.latest_for(name, account_id) diff --git a/backend/druks/usage/reads.py b/backend/druks/usage/reads.py new file mode 100644 index 0000000..7b7aa24 --- /dev/null +++ b/backend/druks/usage/reads.py @@ -0,0 +1,58 @@ +# Usage's shared read helpers: the operator-local day window over finished +# calls and the trend downsampler. The dashboard routes and the agent usage +# service both read through here so every spend-today figure stays identical. +from datetime import UTC, datetime, timedelta +from zoneinfo import ZoneInfo + +from sqlalchemy import Row, select + +from druks.core.utils.time import operator_local_day +from druks.database import db_session +from druks.durable.models import AgentCall +from druks.usage.schemas import UsageHistoryPoint +from druks.user_settings.models import UserSettings + +# Trend ranges for the percent-left sparklines. The 5h window gets one full +# window plus headroom so an exhaustion arc is visible end to end; weekly gets +# the whole week. +FIVE_HOUR_RANGE = timedelta(hours=6) +WEEK_RANGE = timedelta(days=7) + +# The agent read keeps each trend this short so the whole response stays +# within its byte budget. +_HISTORY_POINTS = 8 + + +def list_finished_calls_today(account_id: str) -> tuple[ZoneInfo, datetime, list[Row]]: + # The account's finished calls in the operator-local day, as (timezone, + # local_start, rows of (model, cost_usd, cost_metadata, finished_at)) — + # the shared boundary keeps every spend-today figure identical. + timezone, local_start = operator_local_day(UserSettings.get().timezone, datetime.now(UTC)) + rows = ( + db_session() + .execute( + select( + AgentCall.model, + AgentCall.cost_usd, + AgentCall.cost_metadata, + AgentCall.finished_at, + ) + .where(AgentCall.account_id == account_id) + .where(AgentCall.finished_at.is_not(None)) + .where(AgentCall.finished_at >= local_start.astimezone(UTC)) + .where(AgentCall.finished_at < (local_start + timedelta(days=1)).astimezone(UTC)), + ) + .all() + ) + return timezone, local_start, rows + + +def downsample(points: list[UsageHistoryPoint], *, cap: int) -> list[UsageHistoryPoint]: + # Thin a series to ≤ cap points, always keeping the newest sample (the + # "now" anchor) — it replaces the last strided sample so the cap holds. + if len(points) <= cap: + return points + stride = -(-len(points) // cap) # ceil division + thinned = points[::stride] + thinned[-1] = points[-1] + return thinned diff --git a/backend/druks/usage/routes.py b/backend/druks/usage/routes.py index b68945f..9d9d5b9 100644 --- a/backend/druks/usage/routes.py +++ b/backend/druks/usage/routes.py @@ -8,8 +8,8 @@ from druks.harnesses.models import HarnessConnection from druks.harnesses.registry import get_harnesses from druks.usage import agent -from druks.usage.agent import FIVE_HOUR_RANGE, WEEK_RANGE, downsample, list_finished_calls_today from druks.usage.models import UsageScrape +from druks.usage.reads import FIVE_HOUR_RANGE, WEEK_RANGE, downsample, list_finished_calls_today from druks.usage.schemas import ( AgentUsage, UsageHarnessHistory, From 93e814aa9aa01d9337df8e6939b109419a443f46 Mon Sep 17 00:00:00 2001 From: Paulo Date: Sun, 19 Jul 2026 19:22:56 +0200 Subject: [PATCH 03/21] Decouple the resume route from the agent answer service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resume_run answers the parked run through Run.resume and validate_in_app_answer directly again, off Run and its own HTTPExceptions — the dashboard's in-app resume no longer routes through the shared gate answer service. The gate receipt is still written by _park on any Run.resume, so the 204/409/422 contract holds and nothing is lost. --- backend/druks/api/runs.py | 39 +++++++++------------------------------ 1 file changed, 9 insertions(+), 30 deletions(-) diff --git a/backend/druks/api/runs.py b/backend/druks/api/runs.py index 5eb5cb2..3c4cc8b 100644 --- a/backend/druks/api/runs.py +++ b/backend/druks/api/runs.py @@ -1,14 +1,9 @@ from fastapi import APIRouter, HTTPException, status from druks.api.schemas import ResumeRequest -from druks.durable import agent -from druks.durable.exceptions import ( - GateNotAnswerable, - GateNotOpen, - GateRoundStale, - InvalidGateAnswer, -) -from druks.workflows import Run +from druks.notifications.exceptions import InvalidChoiceError +from druks.notifications.services import validate_in_app_answer +from druks.workflows import Run, RunState router = APIRouter(prefix="/api/runs", tags=["runs"]) @@ -16,31 +11,15 @@ @router.post("/{run_id}/resume", status_code=status.HTTP_204_NO_CONTENT) async def resume_run(run_id: str, body: ResumeRequest) -> None: # The in-app half of a gate: the operator answers the parked run from Druks - # (external gates resume through their own webhook). The dashboard always - # answers the live park, so this echoes the run's own parked_at into the - # shared answer service and keeps its historical wire contract (204 / 409 / - # 422) over the agent taxonomy. + # (external gates resume through their own webhook). run = Run.get(run_id) if not run: raise HTTPException(status.HTTP_404_NOT_FOUND, "run not found") - parked_at = run.input_requested_at - if not parked_at: + ask = run.input_request + if run.state != RunState.PENDING_INPUT.value or not ask: raise HTTPException(status.HTTP_409_CONFLICT, "run is not waiting on an in-app decision") try: - result = await agent.answer_gate( - run_id, - parked_at=parked_at, - control=body.control, - answers=body.answers, - note=body.note, - ) - except (GateNotOpen, GateRoundStale) as error: - raise HTTPException( - status.HTTP_409_CONFLICT, "run is not waiting on an in-app decision" - ) from error - except (GateNotAnswerable, InvalidGateAnswer) as error: + resume_payload = validate_in_app_answer(ask, body.control, body.answers, body.note) + except InvalidChoiceError as error: raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(error)) from error - if result.result == "already_answered": - # This park was already resumed; the double-submit stays the conflict - # this route has always reported. - raise HTTPException(status.HTTP_409_CONFLICT, "run is not waiting on an in-app decision") + await run.resume(**resume_payload) From 19900e263d3c19b14322f9a95b5dc296494c4286 Mon Sep 17 00:00:00 2001 From: Paulo Date: Sun, 19 Jul 2026 19:34:56 +0200 Subject: [PATCH 04/21] Move the platform agent surface into druks/mcp/gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds druks/mcp/gateway as the inbound platform surface beside the outbound MCP-server registry: services (gate read/answer, run cancel, bounded agent-call detail, and usage), the {code, message, retryable} AgentApiError base and its codes, the wire schemas, and the tags=["agent"] routes for /api/agent/* and /api/usage/agent. The durable and usage agent halves collapse into gateway/services; their platform schemas and the exception base move with them, api/app.py mounts the gateway router and renders AgentApiError off it, and build's errors now subclass the moved base. TextSlice, AgentCallSummary and UsageHistoryPoint stay in their domains — read_slice, RunSummary and UsageHarnessHistory still depend on them and nothing in a domain may import the gateway — so the gateway imports them down. --- backend/druks/api/app.py | 4 +- backend/druks/build/exceptions.py | 2 +- backend/druks/durable/exceptions.py | 57 ------------ backend/druks/durable/schemas.py | 40 --------- backend/druks/exceptions.py | 15 ---- backend/druks/mcp/gateway/__init__.py | 0 backend/druks/mcp/gateway/exceptions.py | 67 ++++++++++++++ .../{api/agent.py => mcp/gateway/routes.py} | 42 ++++++--- backend/druks/mcp/gateway/schemas.py | 74 ++++++++++++++++ .../agent.py => mcp/gateway/services.py} | 88 +++++++++++++++++-- backend/druks/usage/agent.py | 71 --------------- backend/druks/usage/routes.py | 17 ---- backend/druks/usage/schemas.py | 26 ------ backend/tests/test_agent_routes.py | 11 +-- backend/tests/test_agent_services.py | 55 ++++++------ 15 files changed, 284 insertions(+), 285 deletions(-) delete mode 100644 backend/druks/exceptions.py create mode 100644 backend/druks/mcp/gateway/__init__.py create mode 100644 backend/druks/mcp/gateway/exceptions.py rename backend/druks/{api/agent.py => mcp/gateway/routes.py} (62%) create mode 100644 backend/druks/mcp/gateway/schemas.py rename backend/druks/{durable/agent.py => mcp/gateway/services.py} (68%) delete mode 100644 backend/druks/usage/agent.py diff --git a/backend/druks/api/app.py b/backend/druks/api/app.py index b69edd5..bc59dc0 100644 --- a/backend/druks/api/app.py +++ b/backend/druks/api/app.py @@ -11,15 +11,15 @@ from druks.accounts.dependencies import current_account from druks.accounts.routes import router as auth_router -from druks.api.agent import router as agent_router from druks.api.artifacts import router as artifacts_router from druks.api.runs import router as runs_router from druks.database import configure_session, create_engine_from_url, db_session from druks.durable.engine import init_dbos, launch, shutdown from druks.events.routes import router as events_router -from druks.exceptions import AgentApiError from druks.extensions.loader import iter_extensions, load from druks.mcp.catalog import load_mcp_catalog +from druks.mcp.gateway.exceptions import AgentApiError +from druks.mcp.gateway.routes import router as agent_router from druks.mcp.routes import router as mcp_router from druks.notifications.routes import external_router as notifications_external_router from druks.notifications.routes import router as notifications_router diff --git a/backend/druks/build/exceptions.py b/backend/druks/build/exceptions.py index f091486..6f50ac0 100644 --- a/backend/druks/build/exceptions.py +++ b/backend/druks/build/exceptions.py @@ -1,4 +1,4 @@ -from druks.exceptions import AgentApiError +from druks.mcp.gateway.exceptions import AgentApiError class InvalidCursor(AgentApiError): diff --git a/backend/druks/durable/exceptions.py b/backend/druks/durable/exceptions.py index 355fcc9..083b3ec 100644 --- a/backend/druks/durable/exceptions.py +++ b/backend/druks/durable/exceptions.py @@ -1,7 +1,5 @@ from typing import ClassVar -from druks.exceptions import AgentApiError - class FatalError(Exception): """End the run as failed on purpose: the message becomes the run's recorded @@ -33,58 +31,3 @@ def __init__(self, gate: str) -> None: "to notify someone directly" ) self.gate = gate - - -class RunNotFound(AgentApiError): - status_code = 404 - code = "RUN_NOT_FOUND" - - def __init__(self, run_id: str) -> None: - super().__init__(f"No run {run_id}.") - - -class GateNotOpen(AgentApiError): - status_code = 409 - code = "GATE_NOT_OPEN" - - def __init__(self, run_id: str) -> None: - super().__init__(f"Run {run_id} is not parked on a gate.") - - -class GateRoundStale(AgentApiError): - status_code = 409 - code = "GATE_ROUND_STALE" - retryable = True - - def __init__(self, run_id: str) -> None: - super().__init__( - f"Run {run_id} has re-parked since the parked_at you read; fetch the gate again." - ) - - -class GateNotAnswerable(AgentApiError): - status_code = 409 - code = "GATE_NOT_ANSWERABLE" - - def __init__(self, run_id: str) -> None: - super().__init__(f"The gate on run {run_id} is answered on its source, not through here.") - - -class InvalidGateAnswer(AgentApiError): - code = "INVALID_GATE_ANSWER" - - -class AgentCallNotFound(AgentApiError): - status_code = 404 - code = "AGENT_CALL_NOT_FOUND" - - def __init__(self, call_id: str) -> None: - super().__init__(f"No agent call {call_id}.") - - -class RunNotActive(AgentApiError): - status_code = 409 - code = "RUN_NOT_ACTIVE" - - def __init__(self, run_id: str) -> None: - super().__init__(f"Run {run_id} already ended.") diff --git a/backend/druks/durable/schemas.py b/backend/druks/durable/schemas.py index 4de31ae..0c0836e 100644 --- a/backend/druks/durable/schemas.py +++ b/backend/druks/durable/schemas.py @@ -290,43 +290,3 @@ def from_run(cls, run: Run, calls: list[AgentCall]) -> "RunSummary": account_username=run.account.username, agent_calls=[AgentCallSummary.from_call(call) for call in calls], ) - - -class ArtifactChunk(BaseResponse): - # A call's renderable output, head-bounded; page the rest through the - # call's transcript files route. - call_id: str - kind: str - title: str - chunk: TextSlice - - -class GateView(BaseResponse): - # Everything needed to answer a parked run in one read: the ask, the - # artifact under review, the reply's JSON Schema, and parked_at — the park - # identity answer_gate must echo back. - run_id: str - gate: str - parked_at: datetime - ask: dict[str, Any] - artifact: ArtifactChunk | None = None - reply_schema: dict[str, Any] - - -class GateAnswerResult(BaseResponse): - run_id: str - parked_at: datetime - result: Literal["answered", "already_answered"] - - -class AgentCallDetail(BaseResponse): - run_id: str - call: AgentCallSummary - transcript: TextSlice - stderr: TextSlice - artifact: ArtifactChunk | None = None - - -class CancelRunResult(BaseResponse): - run_id: str - result: Literal["cancelled", "already_cancelled"] diff --git a/backend/druks/exceptions.py b/backend/druks/exceptions.py deleted file mode 100644 index fef5dfe..0000000 --- a/backend/druks/exceptions.py +++ /dev/null @@ -1,15 +0,0 @@ -from typing import ClassVar - - -class AgentApiError(Exception): - # Base for the agent surface's wire errors: each subclass names its HTTP - # status and stable code, serialized as the one {code, message, retryable} - # response shape. Messages are authored for the caller — never tracebacks, - # paths, or engine internals. retryable=True marks a failure the caller can - # fix by re-reading state and retrying. - status_code: ClassVar[int] = 400 - code: ClassVar[str] = "" - retryable: ClassVar[bool] = False - - -__all__ = ["AgentApiError"] diff --git a/backend/druks/mcp/gateway/__init__.py b/backend/druks/mcp/gateway/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/druks/mcp/gateway/exceptions.py b/backend/druks/mcp/gateway/exceptions.py new file mode 100644 index 0000000..4477bd6 --- /dev/null +++ b/backend/druks/mcp/gateway/exceptions.py @@ -0,0 +1,67 @@ +from typing import ClassVar + + +class AgentApiError(Exception): + # Base for the agent surface's wire errors: each subclass names its HTTP + # status and stable code, serialized as the one {code, message, retryable} + # response shape. Messages are authored for the caller — never tracebacks, + # paths, or engine internals. retryable=True marks a failure the caller can + # fix by re-reading state and retrying. + status_code: ClassVar[int] = 400 + code: ClassVar[str] = "" + retryable: ClassVar[bool] = False + + +class RunNotFound(AgentApiError): + status_code = 404 + code = "RUN_NOT_FOUND" + + def __init__(self, run_id: str) -> None: + super().__init__(f"No run {run_id}.") + + +class GateNotOpen(AgentApiError): + status_code = 409 + code = "GATE_NOT_OPEN" + + def __init__(self, run_id: str) -> None: + super().__init__(f"Run {run_id} is not parked on a gate.") + + +class GateRoundStale(AgentApiError): + status_code = 409 + code = "GATE_ROUND_STALE" + retryable = True + + def __init__(self, run_id: str) -> None: + super().__init__( + f"Run {run_id} has re-parked since the parked_at you read; fetch the gate again." + ) + + +class GateNotAnswerable(AgentApiError): + status_code = 409 + code = "GATE_NOT_ANSWERABLE" + + def __init__(self, run_id: str) -> None: + super().__init__(f"The gate on run {run_id} is answered on its source, not through here.") + + +class InvalidGateAnswer(AgentApiError): + code = "INVALID_GATE_ANSWER" + + +class AgentCallNotFound(AgentApiError): + status_code = 404 + code = "AGENT_CALL_NOT_FOUND" + + def __init__(self, call_id: str) -> None: + super().__init__(f"No agent call {call_id}.") + + +class RunNotActive(AgentApiError): + status_code = 409 + code = "RUN_NOT_ACTIVE" + + def __init__(self, run_id: str) -> None: + super().__init__(f"Run {run_id} already ended.") diff --git a/backend/druks/api/agent.py b/backend/druks/mcp/gateway/routes.py similarity index 62% rename from backend/druks/api/agent.py rename to backend/druks/mcp/gateway/routes.py index 31478fd..71d3959 100644 --- a/backend/druks/api/agent.py +++ b/backend/druks/mcp/gateway/routes.py @@ -1,13 +1,21 @@ from typing import Annotated -from fastapi import APIRouter, Body +from fastapi import APIRouter, Body, Depends from pydantic import AwareDatetime, BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel -from druks.durable import agent -from druks.durable.schemas import AgentCallDetail, CancelRunResult, GateAnswerResult, GateView +from druks.accounts.dependencies import current_account +from druks.accounts.models import Account +from druks.mcp.gateway import services +from druks.mcp.gateway.schemas import ( + AgentCallDetail, + AgentUsage, + CancelRunResult, + GateAnswerResult, + GateView, +) -router = APIRouter(prefix="/api/agent", tags=["agent"]) +router = APIRouter(tags=["agent"]) class AnswerGateRequest(BaseModel): @@ -23,23 +31,23 @@ class AnswerGateRequest(BaseModel): @router.get( - "/gates/{run_id}", + "/api/agent/gates/{run_id}", operation_id="get_gate", response_model=GateView, response_model_by_alias=True, ) async def get_gate(run_id: str) -> GateView: - return agent.get_gate(run_id) + return services.get_gate(run_id) @router.post( - "/gates/{run_id}/answer", + "/api/agent/gates/{run_id}/answer", operation_id="answer_gate", response_model=GateAnswerResult, response_model_by_alias=True, ) async def answer_gate(run_id: str, body: AnswerGateRequest) -> GateAnswerResult: - return await agent.answer_gate( + return await services.answer_gate( run_id, parked_at=body.parked_at, control=body.control, @@ -49,17 +57,17 @@ async def answer_gate(run_id: str, body: AnswerGateRequest) -> GateAnswerResult: @router.get( - "/agent-calls/{call_id}", + "/api/agent/agent-calls/{call_id}", operation_id="get_agent_call", response_model=AgentCallDetail, response_model_by_alias=True, ) async def get_agent_call(call_id: str) -> AgentCallDetail: - return agent.get_agent_call(call_id) + return services.get_agent_call(call_id) @router.post( - "/runs/{run_id}/cancel", + "/api/agent/runs/{run_id}/cancel", operation_id="cancel_run", response_model=CancelRunResult, response_model_by_alias=True, @@ -67,4 +75,14 @@ async def get_agent_call(call_id: str) -> AgentCallDetail: async def cancel_run( run_id: str, reason: Annotated[str, Body(embed=True, min_length=1, max_length=500)] ) -> CancelRunResult: - return await agent.cancel_run(run_id, reason=reason) + return await services.cancel_run(run_id, reason=reason) + + +@router.get( + "/api/usage/agent", + operation_id="get_usage", + response_model=AgentUsage, + response_model_by_alias=True, +) +async def get_usage(account: Account = Depends(current_account)) -> AgentUsage: + return services.get_usage(account) diff --git a/backend/druks/mcp/gateway/schemas.py b/backend/druks/mcp/gateway/schemas.py new file mode 100644 index 0000000..6a5cd56 --- /dev/null +++ b/backend/druks/mcp/gateway/schemas.py @@ -0,0 +1,74 @@ +from datetime import datetime +from typing import Any, Literal + +from pydantic import Field + +from druks.durable.schemas import AgentCallSummary, TextSlice +from druks.schemas import BaseResponse +from druks.usage.schemas import UsageHistoryPoint + + +class ArtifactChunk(BaseResponse): + # A call's renderable output, head-bounded; page the rest through the + # call's transcript files route. + call_id: str + kind: str + title: str + chunk: TextSlice + + +class GateView(BaseResponse): + # Everything needed to answer a parked run in one read: the ask, the + # artifact under review, the reply's JSON Schema, and parked_at — the park + # identity answer_gate must echo back. + run_id: str + gate: str + parked_at: datetime + ask: dict[str, Any] + artifact: ArtifactChunk | None = None + reply_schema: dict[str, Any] + + +class GateAnswerResult(BaseResponse): + run_id: str + parked_at: datetime + result: Literal["answered", "already_answered"] + + +class AgentCallDetail(BaseResponse): + run_id: str + call: AgentCallSummary + transcript: TextSlice + stderr: TextSlice + artifact: ArtifactChunk | None = None + + +class CancelRunResult(BaseResponse): + run_id: str + result: Literal["cancelled", "already_cancelled"] + + +class AgentHarnessUsage(BaseResponse): + # One harness's quota for the agent surface: the latest snapshot's facts + # plus a short percent-left trend per window, oldest first. + name: str + is_connected: bool = False + plan_tier: str | None = None + five_hour_percent_left: int | None = None + five_hour_resets_at: datetime | None = None + week_percent_left: int | None = None + week_resets_at: datetime | None = None + is_unlimited: bool = False + scraped_at: datetime | None = None + five_hour_history: list[UsageHistoryPoint] = Field(default_factory=list) + week_history: list[UsageHistoryPoint] = Field(default_factory=list) + + +class AgentUsage(BaseResponse): + # The caller's spend for the operator-local day plus per-harness quota. + day: str + timezone: str + spend_today_usd: float + tokens_today: int + runs_today: int + harnesses: list[AgentHarnessUsage] = Field(default_factory=list) diff --git a/backend/druks/durable/agent.py b/backend/druks/mcp/gateway/services.py similarity index 68% rename from backend/druks/durable/agent.py rename to backend/druks/mcp/gateway/services.py index 4824b75..8e0d5b4 100644 --- a/backend/druks/durable/agent.py +++ b/backend/druks/mcp/gateway/services.py @@ -1,11 +1,20 @@ -# The durable half of the agent surface: gate read/answer, bounded call -# detail, and run cancel — shared by the /api/agent routes and the MCP tools. -from datetime import datetime +# The inbound agent gateway's platform operations over shared primitives: gate +# read/answer and cancel on runs, bounded agent-call detail, and the caller's +# quota and spend — served to the /api/agent and /api/usage/agent routes and the +# MCP tools. +from datetime import UTC, datetime from typing import Any +from druks.accounts.models import Account from druks.database import db_session from druks.durable.enums import RunState -from druks.durable.exceptions import ( +from druks.durable.models import AgentCall, Artifact, Run +from druks.durable.reads import read_slice +from druks.durable.schemas import AgentCallSummary +from druks.harnesses.artifacts import normalize_token_usage +from druks.harnesses.models import HarnessConnection +from druks.harnesses.registry import get_harnesses +from druks.mcp.gateway.exceptions import ( AgentCallNotFound, GateNotAnswerable, GateNotOpen, @@ -14,19 +23,27 @@ RunNotActive, RunNotFound, ) -from druks.durable.models import AgentCall, Artifact, Run -from druks.durable.reads import read_slice -from druks.durable.schemas import ( +from druks.mcp.gateway.schemas import ( AgentCallDetail, - AgentCallSummary, + AgentHarnessUsage, + AgentUsage, ArtifactChunk, CancelRunResult, GateAnswerResult, GateView, - clip, ) from druks.notifications.exceptions import InvalidChoiceError from druks.notifications.services import validate_in_app_answer +from druks.schemas import clip +from druks.usage.models import UsageScrape +from druks.usage.reads import ( + _HISTORY_POINTS, + FIVE_HOUR_RANGE, + WEEK_RANGE, + downsample, + list_finished_calls_today, +) +from druks.usage.schemas import UsageHistoryPoint _TRANSCRIPT_TAIL_BYTES = 8 * 1024 _STDERR_TAIL_BYTES = 4 * 1024 @@ -181,3 +198,56 @@ def _reply_schema(ask: dict[str, Any]) -> dict[str, Any]: "required": ["control"], "additionalProperties": False, } + + +def get_usage(account: Account) -> AgentUsage: + now = datetime.now(UTC) + timezone, local_start, rows = list_finished_calls_today(account.id) + spend = 0.0 + tokens = 0 + for _, cost_usd, cost_metadata, _ in rows: + if cost_usd is not None: + spend += float(cost_usd) + usage = normalize_token_usage(cost_metadata) + if usage: + tokens += usage["total_tokens"] + return AgentUsage( + day=local_start.date().isoformat(), + timezone=str(timezone), + spend_today_usd=round(spend, 4), + tokens_today=tokens, + runs_today=len(rows), + harnesses=[_harness_usage(h.name, account.id, now=now) for h in get_harnesses()], + ) + + +def _harness_usage(name: str, account_id: str, *, now: datetime) -> AgentHarnessUsage: + is_connected = bool(HarnessConnection.get_for_account(name, account_id)) + row = UsageScrape.latest_for(name, account_id) + if not row: + return AgentHarnessUsage(name=name, is_connected=is_connected) + history = UsageScrape.history_for(name, account_id, since=now - WEEK_RANGE) + five_hour_cutoff = now - FIVE_HOUR_RANGE + five_hour = [ + UsageHistoryPoint(t=point.scraped_at, pct=point.five_hour_percent_left) + for point in history + if point.five_hour_percent_left is not None and point.scraped_at >= five_hour_cutoff + ] + week = [ + UsageHistoryPoint(t=point.scraped_at, pct=point.week_percent_left) + for point in history + if point.week_percent_left is not None + ] + return AgentHarnessUsage( + name=name, + is_connected=is_connected, + plan_tier=clip(row.plan_tier, 64), + five_hour_percent_left=row.five_hour_percent_left, + five_hour_resets_at=row.five_hour_resets_at, + week_percent_left=row.week_percent_left, + week_resets_at=row.week_resets_at, + is_unlimited=row.unlimited, + scraped_at=row.scraped_at, + five_hour_history=downsample(five_hour, cap=_HISTORY_POINTS), + week_history=downsample(week, cap=_HISTORY_POINTS), + ) diff --git a/backend/druks/usage/agent.py b/backend/druks/usage/agent.py deleted file mode 100644 index 72d6d5b..0000000 --- a/backend/druks/usage/agent.py +++ /dev/null @@ -1,71 +0,0 @@ -# Usage's half of the agent surface: one pure read of the caller's quota and -# today's spend — no poll trigger; refresh stays route-driven. -from datetime import UTC, datetime - -from druks.accounts.models import Account -from druks.harnesses.artifacts import normalize_token_usage -from druks.harnesses.models import HarnessConnection -from druks.harnesses.registry import get_harnesses -from druks.schemas import clip -from druks.usage.models import UsageScrape -from druks.usage.reads import ( - _HISTORY_POINTS, - FIVE_HOUR_RANGE, - WEEK_RANGE, - downsample, - list_finished_calls_today, -) -from druks.usage.schemas import AgentHarnessUsage, AgentUsage, UsageHistoryPoint - - -def get_usage(account: Account) -> AgentUsage: - now = datetime.now(UTC) - timezone, local_start, rows = list_finished_calls_today(account.id) - spend = 0.0 - tokens = 0 - for _, cost_usd, cost_metadata, _ in rows: - if cost_usd is not None: - spend += float(cost_usd) - usage = normalize_token_usage(cost_metadata) - if usage: - tokens += usage["total_tokens"] - return AgentUsage( - day=local_start.date().isoformat(), - timezone=str(timezone), - spend_today_usd=round(spend, 4), - tokens_today=tokens, - runs_today=len(rows), - harnesses=[_harness_usage(h.name, account.id, now=now) for h in get_harnesses()], - ) - - -def _harness_usage(name: str, account_id: str, *, now: datetime) -> AgentHarnessUsage: - is_connected = bool(HarnessConnection.get_for_account(name, account_id)) - row = UsageScrape.latest_for(name, account_id) - if not row: - return AgentHarnessUsage(name=name, is_connected=is_connected) - history = UsageScrape.history_for(name, account_id, since=now - WEEK_RANGE) - five_hour_cutoff = now - FIVE_HOUR_RANGE - five_hour = [ - UsageHistoryPoint(t=point.scraped_at, pct=point.five_hour_percent_left) - for point in history - if point.five_hour_percent_left is not None and point.scraped_at >= five_hour_cutoff - ] - week = [ - UsageHistoryPoint(t=point.scraped_at, pct=point.week_percent_left) - for point in history - if point.week_percent_left is not None - ] - return AgentHarnessUsage( - name=name, - is_connected=is_connected, - plan_tier=clip(row.plan_tier, 64), - five_hour_percent_left=row.five_hour_percent_left, - five_hour_resets_at=row.five_hour_resets_at, - week_percent_left=row.week_percent_left, - week_resets_at=row.week_resets_at, - is_unlimited=row.unlimited, - scraped_at=row.scraped_at, - five_hour_history=downsample(five_hour, cap=_HISTORY_POINTS), - week_history=downsample(week, cap=_HISTORY_POINTS), - ) diff --git a/backend/druks/usage/routes.py b/backend/druks/usage/routes.py index 9d9d5b9..f77d0f5 100644 --- a/backend/druks/usage/routes.py +++ b/backend/druks/usage/routes.py @@ -7,11 +7,9 @@ from druks.harnesses.artifacts import normalize_token_usage from druks.harnesses.models import HarnessConnection from druks.harnesses.registry import get_harnesses -from druks.usage import agent from druks.usage.models import UsageScrape from druks.usage.reads import FIVE_HOUR_RANGE, WEEK_RANGE, downsample, list_finished_calls_today from druks.usage.schemas import ( - AgentUsage, UsageHarnessHistory, UsageHarnessSummary, UsageHarnessToday, @@ -137,21 +135,6 @@ async def get_usage_today(account: Account = Depends(current_account)) -> UsageT ) -# /api/usage/agent the agent surface - -agent_router = APIRouter(tags=["agent"]) - - -@agent_router.get( - "/agent", - operation_id="get_usage", - response_model=AgentUsage, - response_model_by_alias=True, -) -async def get_agent_usage(account: Account = Depends(current_account)) -> AgentUsage: - return agent.get_usage(account) - - def _harness_history(name: str, account_id: str, *, now: datetime) -> UsageHarnessHistory: rows = UsageScrape.history_for(name, account_id, since=now - WEEK_RANGE) five_hour_cutoff = now - FIVE_HOUR_RANGE diff --git a/backend/druks/usage/schemas.py b/backend/druks/usage/schemas.py index 117dec5..1868fb3 100644 --- a/backend/druks/usage/schemas.py +++ b/backend/druks/usage/schemas.py @@ -85,29 +85,3 @@ class UsageTodayResponse(BaseResponse): day: str timezone: str harnesses: list[UsageHarnessToday] - - -class AgentHarnessUsage(BaseResponse): - # One harness's quota for the agent surface: the latest snapshot's facts - # plus a short percent-left trend per window, oldest first. - name: str - is_connected: bool = False - plan_tier: str | None = None - five_hour_percent_left: int | None = None - five_hour_resets_at: datetime | None = None - week_percent_left: int | None = None - week_resets_at: datetime | None = None - is_unlimited: bool = False - scraped_at: datetime | None = None - five_hour_history: list[UsageHistoryPoint] = Field(default_factory=list) - week_history: list[UsageHistoryPoint] = Field(default_factory=list) - - -class AgentUsage(BaseResponse): - # The caller's spend for the operator-local day plus per-harness quota. - day: str - timezone: str - spend_today_usd: float - tokens_today: int - runs_today: int - harnesses: list[AgentHarnessUsage] = Field(default_factory=list) diff --git a/backend/tests/test_agent_routes.py b/backend/tests/test_agent_routes.py index befe610..95a32c4 100644 --- a/backend/tests/test_agent_routes.py +++ b/backend/tests/test_agent_routes.py @@ -13,10 +13,9 @@ from druks.accounts.models import Account from druks.build import agent as build_agent from druks.build.workflows import BuildWorkflow -from druks.durable import agent as durable_agent from druks.durable.models import Run from druks.durable.reads import read_transcript_chunk -from druks.usage import agent as usage_agent +from druks.mcp.gateway import services from fastapi.testclient import TestClient _IN_APP_ASK = { @@ -132,7 +131,7 @@ def test_get_gate_then_answer_roundtrip(client: TestClient, db_session, resume_s view = client.get(f"/api/agent/gates/{run.id}") assert view.status_code == 200 data = view.json() - assert data == durable_agent.get_gate(run.id).model_dump(mode="json", by_alias=True) + assert data == services.get_gate(run.id).model_dump(mode="json", by_alias=True) answered = client.post( f"/api/agent/gates/{run.id}/answer", @@ -249,9 +248,7 @@ def test_work_routes_match_the_services(client: TestClient, db_session, account) call_id = detail.json()["runs"][0]["agentCalls"][0]["id"] call = client.get(f"/api/agent/agent-calls/{call_id}") assert call.status_code == 200 - assert call.json() == durable_agent.get_agent_call(call_id).model_dump( - mode="json", by_alias=True - ) + assert call.json() == services.get_agent_call(call_id).model_dump(mode="json", by_alias=True) def test_board_status_matches_list_work(client: TestClient, db_session, account): @@ -341,7 +338,7 @@ def test_usage_agent_route_matches_the_service(client: TestClient, db_session, a response = client.get("/api/usage/agent") assert response.status_code == 200 body = response.json() - assert body == usage_agent.get_usage(account).model_dump(mode="json", by_alias=True) + assert body == services.get_usage(account).model_dump(mode="json", by_alias=True) assert len(response.content) <= 4 * 1024 today = client.get("/api/usage/today").json() diff --git a/backend/tests/test_agent_services.py b/backend/tests/test_agent_services.py index 15714b4..190fc7a 100644 --- a/backend/tests/test_agent_services.py +++ b/backend/tests/test_agent_services.py @@ -8,8 +8,10 @@ from druks.build import agent as build_agent from druks.build.exceptions import InvalidCursor, WorkItemNotFound from druks.build.workflows import BuildWorkflow -from druks.durable import agent as durable_agent -from druks.durable.exceptions import ( +from druks.durable.models import Artifact, Run +from druks.durable.reads import read_slice +from druks.mcp.gateway import services +from druks.mcp.gateway.exceptions import ( AgentCallNotFound, GateNotAnswerable, GateNotOpen, @@ -18,9 +20,6 @@ RunNotActive, RunNotFound, ) -from druks.durable.models import Artifact, Run -from druks.durable.reads import read_slice -from druks.usage import agent as usage_agent from druks.usage.models import UsageScrape pytestmark = pytest.mark.usefixtures("_data_dir") @@ -177,7 +176,7 @@ def test_get_gate_returns_ask_schema_and_parked_at(db_session): question = {"id": "q1", "prompt": "Which db?", "options": [{"id": "pg", "label": "Postgres"}]} run = _park(db_session, item.id, ask=_in_app_ask([question])) - view = durable_agent.get_gate(run.id) + view = services.get_gate(run.id) assert view.run_id == run.id assert view.gate == "review" @@ -198,7 +197,7 @@ def test_get_gate_bounds_an_agent_authored_ask(db_session): } run = _park(db_session, item.id, ask=_in_app_ask([question])) - view = durable_agent.get_gate(run.id) + view = services.get_gate(run.id) prompt = view.ask["questions"][0]["prompt"] label = view.ask["questions"][0]["options"][0]["label"] @@ -217,7 +216,7 @@ def test_get_gate_serves_a_bounded_artifact_chunk(db_session): call_dir=call.call_dir, call_id=call.id, kind="markdown", title="Plan", content="x" * 10240 ) - view = durable_agent.get_gate(run.id) + view = services.get_gate(run.id) assert view.artifact is not None assert view.artifact.call_id == call.id @@ -228,12 +227,12 @@ def test_get_gate_serves_a_bounded_artifact_chunk(db_session): def test_get_gate_refuses_when_not_parked_or_external(db_session): with pytest.raises(RunNotFound): - durable_agent.get_gate("no-such-run") + services.get_gate("no-such-run") item = make_test_work_item(repo="o/r", title="t") running = seed_build_run(db_session, work_item_id=item.id, state="running") with pytest.raises(GateNotOpen): - durable_agent.get_gate(running.id) + services.get_gate(running.id) external_item = make_test_work_item(repo="o/r2", title="t") external = _park( @@ -242,14 +241,14 @@ def test_get_gate_refuses_when_not_parked_or_external(db_session): ask={"presentation": "external", "label": "Answer on the ticket"}, ) with pytest.raises(GateNotAnswerable): - durable_agent.get_gate(external.id) + services.get_gate(external.id) async def test_answer_gate_resumes_through_run_resume(db_session, resume_spy): item = make_test_work_item(repo="o/r", title="t") run = _park(db_session, item.id) - result = await durable_agent.answer_gate( + result = await services.answer_gate( run.id, parked_at=run.input_requested_at, control="approve", @@ -270,7 +269,7 @@ async def test_answer_gate_uses_the_receipt_for_already_answered(db_session, res run.answered_parked_at = parked_at db_session.flush() - result = await durable_agent.answer_gate( + result = await services.answer_gate( run.id, parked_at=parked_at, control="approve", answers={}, note="" ) @@ -280,21 +279,21 @@ async def test_answer_gate_uses_the_receipt_for_already_answered(db_session, res async def test_answer_gate_error_taxonomy(db_session, resume_spy): with pytest.raises(RunNotFound): - await durable_agent.answer_gate( + await services.answer_gate( "no-such-run", parked_at=datetime.now(UTC), control="approve", answers={}, note="" ) item = make_test_work_item(repo="o/r", title="t") finished = seed_build_run(db_session, work_item_id=item.id, state="finished") with pytest.raises(GateNotOpen): - await durable_agent.answer_gate( + await services.answer_gate( finished.id, parked_at=datetime.now(UTC), control="approve", answers={}, note="" ) parked_item = make_test_work_item(repo="o/r2", title="t") run = _park(db_session, parked_item.id) with pytest.raises(GateRoundStale): - await durable_agent.answer_gate( + await services.answer_gate( run.id, parked_at=run.input_requested_at - timedelta(seconds=5), control="approve", @@ -302,11 +301,11 @@ async def test_answer_gate_error_taxonomy(db_session, resume_spy): note="", ) with pytest.raises(InvalidGateAnswer): - await durable_agent.answer_gate( + await services.answer_gate( run.id, parked_at=run.input_requested_at, control="merge", answers={}, note="" ) with pytest.raises(InvalidGateAnswer): - await durable_agent.answer_gate( + await services.answer_gate( run.id, parked_at=run.input_requested_at, control="approve", @@ -321,7 +320,7 @@ async def test_answer_gate_error_taxonomy(db_session, resume_spy): ask={"presentation": "external", "label": "Answer on the ticket"}, ) with pytest.raises(GateNotAnswerable): - await durable_agent.answer_gate( + await services.answer_gate( external.id, parked_at=external.input_requested_at, control="approve", @@ -347,7 +346,7 @@ def test_get_agent_call_serves_bounded_tails(db_session): call_dir=call_dir, call_id=call.id, kind="markdown", title="Out", content="a" * 10240 ) - detail = durable_agent.get_agent_call(call.id) + detail = services.get_agent_call(call.id) assert detail.run_id == call.run_id assert detail.call.id == call.id @@ -362,7 +361,7 @@ def test_get_agent_call_serves_bounded_tails(db_session): assert len(detail.artifact.chunk.text.encode()) <= 4096 with pytest.raises(AgentCallNotFound): - durable_agent.get_agent_call("no-such-call") + services.get_agent_call("no-such-call") def test_get_agent_call_without_files_reads_empty(db_session): @@ -370,7 +369,7 @@ def test_get_agent_call_without_files_reads_empty(db_session): call = seed_agent_run() - detail = durable_agent.get_agent_call(call.id) + detail = services.get_agent_call(call.id) assert detail.transcript.text == "" assert detail.transcript.eof is True @@ -385,22 +384,22 @@ async def test_cancel_run_paths(db_session): item = make_test_work_item(repo="o/r", title="t") run = seed_build_run(db_session, work_item_id=item.id, state="running") - result = await durable_agent.cancel_run(run.id, reason="stuck") + result = await services.cancel_run(run.id, reason="stuck") assert result.result == "cancelled" db_session.expire_all() assert Run.get(run.id).state == "cancelled" assert Run.get(run.id).failure == "stuck" - again = await durable_agent.cancel_run(run.id, reason="stuck") + again = await services.cancel_run(run.id, reason="stuck") assert again.result == "already_cancelled" finished_item = make_test_work_item(repo="o/r2", title="t") finished = seed_build_run(db_session, work_item_id=finished_item.id, state="finished") with pytest.raises(RunNotActive): - await durable_agent.cancel_run(finished.id, reason="late") + await services.cancel_run(finished.id, reason="late") with pytest.raises(RunNotFound): - await durable_agent.cancel_run("no-such-run", reason="x") + await services.cancel_run("no-such-run", reason="x") # ---- work board ----------------------------------------------------------- @@ -594,7 +593,7 @@ def test_get_usage_is_a_bounded_pure_read(db_session, account): week_resets_at=now + timedelta(days=3), ).save() - usage = usage_agent.get_usage(account) + usage = services.get_usage(account) assert usage.runs_today == 30 assert usage.spend_today_usd == pytest.approx(15.0) @@ -628,7 +627,7 @@ def test_get_usage_only_counts_the_callers_spend(db_session, account): ) db_session.flush() - usage = usage_agent.get_usage(account) + usage = services.get_usage(account) assert usage.runs_today == 0 assert usage.spend_today_usd == 0.0 From 655df54976aee25102ec1ef2f1be27cff205aada Mon Sep 17 00:00:00 2001 From: Paulo Date: Sun, 19 Jul 2026 19:41:03 +0200 Subject: [PATCH 05/21] Park build's agent operations pending the extension registry The build work board, item detail, and dispatch are app-specific, not platform primitives, so they wait for the extension route/tool-contribution seam rather than living in the platform gateway. Deletes build/agent.py and build/exceptions.py (preserved on eng/build-agent-ops-parked), strips the /api/build/agent routes and their Agent* response schemas, and restores Links.from_work_item to its unclipped dashboard shape. The build-op tests are parked with them; the gate, answer, cancel, agent-call, usage, and receipt tests stay on the gateway. --- backend/druks/build/agent.py | 127 --------------------- backend/druks/build/exceptions.py | 16 --- backend/druks/build/routes.py | 75 +------------ backend/druks/build/schemas.py | 110 +----------------- backend/tests/test_agent_routes.py | 80 +------------- backend/tests/test_agent_services.py | 160 --------------------------- 6 files changed, 4 insertions(+), 564 deletions(-) delete mode 100644 backend/druks/build/agent.py delete mode 100644 backend/druks/build/exceptions.py diff --git a/backend/druks/build/agent.py b/backend/druks/build/agent.py deleted file mode 100644 index 9adc985..0000000 --- a/backend/druks/build/agent.py +++ /dev/null @@ -1,127 +0,0 @@ -# Build's half of the agent surface: the work board, one item's detail, and -# dispatch — shared by the /api/build/agent routes and the MCP tools. -import base64 -from datetime import datetime - -from sqlalchemy import select, tuple_ - -from druks.accounts import sessions -from druks.accounts.models import Account -from druks.build.exceptions import InvalidCursor, WorkItemNotFound -from druks.build.models import WorkItem -from druks.build.schemas import ( - AgentDispatchResult, - AgentWorkItem, - AgentWorkItemDetail, - AgentWorkPage, -) -from druks.build.workflows import BuildWorkflow -from druks.database import db_session -from druks.durable.enums import ACTIVE_STATES, RunState -from druks.durable.models import Run -from druks.durable.reads import get_subject_status, list_recent_runs - -# Sized so a page of maximal rows serializes under the 12KiB budget. -_PAGE_LIMIT = 12 -_RECENT_RUNS_LIMIT = 5 -_RUN_CALLS_LIMIT = 5 - -# The lifecycle filters, as predicates on the item's build run — the dedup -# anchor and resume handle. "mine" is the caller's runs; the rest read the -# run's derived state. -_STATE_FILTERS = { - "parked": (RunState.PENDING_INPUT.value,), - "active": tuple(state.value for state in ACTIVE_STATES), - "failed": (RunState.FAILED.value,), -} - - -def list_work( - account: Account, *, filter: str | None = None, cursor: str | None = None -) -> AgentWorkPage: - stmt = ( - select(WorkItem) - .order_by(WorkItem.updated_at.desc(), WorkItem.id.desc()) - .limit(_PAGE_LIMIT + 1) - ) - if filter: - stmt = stmt.join(Run, Run.id == WorkItem.build_run_id) - if filter == "mine": - stmt = stmt.where(Run.account_id == account.id) - else: - stmt = stmt.where(Run.state.in_(_STATE_FILTERS[filter])) - if cursor: - after_at, after_id = _decode_cursor(cursor) - stmt = stmt.where(tuple_(WorkItem.updated_at, WorkItem.id) < (after_at, after_id)) - items = list(db_session().scalars(stmt)) - next_cursor = None - if len(items) > _PAGE_LIMIT: - items = items[:_PAGE_LIMIT] - next_cursor = _encode_cursor(items[-1]) - return AgentWorkPage( - items=[ - AgentWorkItem.from_work_item(item, get_subject_status("work_item", str(item.id))) - for item in items - ], - next_cursor=next_cursor, - ) - - -def get_work_item(work_item_id: int) -> AgentWorkItemDetail: - item = WorkItem.get(work_item_id) - if not item: - raise WorkItemNotFound(str(work_item_id)) - subject_id = str(item.id) - return AgentWorkItemDetail.from_work_item( - item, - get_subject_status("work_item", subject_id), - list_recent_runs( - "work_item", subject_id, limit=_RECENT_RUNS_LIMIT, calls_limit=_RUN_CALLS_LIMIT - ), - ) - - -async def dispatch( - account: Account, - *, - work_item_id: int | None = None, - source: str | None = None, - ticket_ref: str | None = None, -) -> AgentDispatchResult: - # The route holds the addressing to exactly one mode; both resolve an - # existing item — the closed error taxonomy has no room for intake here. - if work_item_id: - item = WorkItem.get(work_item_id) - ref = str(work_item_id) - else: - item = WorkItem.get_for_remote_key(source=source or "", remote_key=ticket_ref or "") - ref = f"{source}:{ticket_ref}" - if not item: - raise WorkItemNotFound(ref) - # Not every caller arrives through the session gate (the MCP boundary has - # none), so the service stamps ambient attribution itself — start() inherits - # it when the item carries no assignee. - sessions.current_account_id.set(account.id) - run_id = await BuildWorkflow.dispatch(work_item_id=item.id) - run = Run.get(run_id) - assert run is not None # dispatch just created (or handed back) this run's row - return AgentDispatchResult( - work_item_id=item.id, - run_id=run_id, - is_owned_by_caller=run.account_id == account.id, - note="run_id is the active build for this work item; dispatching again returns it.", - ) - - -def _encode_cursor(item: WorkItem) -> str: - key = f"{item.updated_at.isoformat()}|{item.id}" - return base64.urlsafe_b64encode(key.encode()).decode() - - -def _decode_cursor(cursor: str) -> tuple[datetime, int]: - try: - updated_at, _, item_id = base64.urlsafe_b64decode(cursor.encode()).decode().partition("|") - return datetime.fromisoformat(updated_at), int(item_id) - except ValueError as error: - # Covers base64, UTF-8, timestamp, and int parse failures alike. - raise InvalidCursor() from error diff --git a/backend/druks/build/exceptions.py b/backend/druks/build/exceptions.py deleted file mode 100644 index 6f50ac0..0000000 --- a/backend/druks/build/exceptions.py +++ /dev/null @@ -1,16 +0,0 @@ -from druks.mcp.gateway.exceptions import AgentApiError - - -class InvalidCursor(AgentApiError): - code = "INVALID_CURSOR" - - def __init__(self) -> None: - super().__init__("The cursor is not one this API issued; restart from the first page.") - - -class WorkItemNotFound(AgentApiError): - status_code = 404 - code = "WORK_ITEM_NOT_FOUND" - - def __init__(self, ref: str) -> None: - super().__init__(f"No work item {ref}.") diff --git a/backend/druks/build/routes.py b/backend/druks/build/routes.py index fca1980..682f252 100644 --- a/backend/druks/build/routes.py +++ b/backend/druks/build/routes.py @@ -1,20 +1,11 @@ import logging -from typing import Literal -from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response, status -from fastapi.exceptions import RequestValidationError -from pydantic import BaseModel +from fastapi import APIRouter, Body, HTTPException, Query, Response, status from sqlalchemy import func, select, update -from druks.accounts.dependencies import current_account -from druks.accounts.models import Account -from druks.build import agent from druks.build.models import Project, ProjectRepo, WorkItem from druks.build.schemas import ( AddProjectRepoRequest, - AgentDispatchResult, - AgentWorkItemDetail, - AgentWorkPage, CreateProjectRequest, DashboardItem, GitHubReposResponse, @@ -264,67 +255,3 @@ async def list_work_items_history( # reads the event log directly, already ordered newest-handoff-first and bounded. items = [DashboardItem.from_work_item(wi) for wi in WorkItem.list_handoff(limit=clamped)] return WorkItemsHistoryResponse(items=items) - - -# /api/build/agent the agent surface - -agent_router = APIRouter(prefix="/agent", tags=["agent"]) - - -class DispatchRequest(BaseModel): - # Address the item one way: its druks id, or its ticket in the tracker. - work_item_id: int | None = None - source: Literal["linear", "jira"] | None = None - ticket_ref: str | None = None - - -@agent_router.get( - "/work", - operation_id="list_work", - response_model=AgentWorkPage, - response_model_by_alias=True, -) -async def list_work( - filter: Literal["mine", "parked", "active", "failed"] | None = None, - cursor: str | None = None, - account: Account = Depends(current_account), -) -> AgentWorkPage: - return agent.list_work(account, filter=filter, cursor=cursor) - - -@agent_router.get( - "/work-items/{work_item_id}", - operation_id="get_work_item", - response_model=AgentWorkItemDetail, - response_model_by_alias=True, -) -async def get_work_item(work_item_id: int) -> AgentWorkItemDetail: - return agent.get_work_item(work_item_id) - - -@agent_router.post( - "/dispatch", - operation_id="dispatch", - response_model=AgentDispatchResult, - response_model_by_alias=True, -) -async def dispatch( - body: DispatchRequest, account: Account = Depends(current_account) -) -> AgentDispatchResult: - if bool(body.work_item_id) == bool(body.source and body.ticket_ref): - # The addressing XOR is request shape, so it wears Pydantic's envelope. - raise RequestValidationError( - [ - { - "loc": ("body",), - "msg": "Address exactly one of work_item_id, or source + ticket_ref.", - "type": "value_error", - } - ] - ) - return await agent.dispatch( - account, - work_item_id=body.work_item_id, - source=body.source, - ticket_ref=body.ticket_ref, - ) diff --git a/backend/druks/build/schemas.py b/backend/druks/build/schemas.py index b8b1477..95c3eae 100644 --- a/backend/druks/build/schemas.py +++ b/backend/druks/build/schemas.py @@ -5,7 +5,6 @@ from druks.build.models import Project, ProjectRepo, WorkItem from druks.durable.enums import RunState -from druks.durable.schemas import RunSummary, SubjectStatus, clip from druks.schemas import BaseResponse from druks.workflows import SubjectSummary @@ -104,15 +103,9 @@ class Links(BaseResponse): ticket: str | None = None @classmethod - def from_work_item(cls, item: WorkItem, *, ticket_clip: int | None = None) -> "Links": - # The tracker URL is client-supplied and unbounded; budgeted (agent) - # projections pass ticket_clip so a page holds its byte contract — the - # dashboard keeps the whole URL for click-through. + def from_work_item(cls, item: WorkItem) -> "Links": pr = f"https://github.com/{item.repo}/pull/{item.pr_number}" if item.pr_number else None - ticket = item.remote_url - if ticket_clip: - ticket = clip(ticket, ticket_clip) - return cls(repo=f"https://github.com/{item.repo}", pr=pr, ticket=ticket) + return cls(repo=f"https://github.com/{item.repo}", pr=pr, ticket=item.remote_url) class WorkItemSummary(SubjectSummary): @@ -192,102 +185,3 @@ def from_work_item(cls, item: WorkItem) -> "DashboardItem": class WorkItemsHistoryResponse(BaseResponse): items: list[DashboardItem] - - -# The agent surface clips titles so a page of rows holds its byte budget. -_TITLE_CLIP = 120 -# SubjectStatus.failure is unbounded on the dashboard; the agent surface -# bounds it the same way the run summaries bound theirs. -_FAILURE_CLIP = 160 -# The tracker URL is client-supplied; real ticket URLs sit well under this. -_URL_CLIP = 200 - - -def _bounded_status(status: SubjectStatus) -> SubjectStatus: - return status.model_copy(update={"failure": clip(status.failure, _FAILURE_CLIP)}) - - -class AgentWorkItem(BaseResponse): - # One board row for the agent surface: identity + links + the platform's - # SubjectStatus facts, free text clipped for the page budget. - work_item_id: int - title: str - source: str - ticket_ref: str | None = None - repo: str - pr_number: int | None = None - branch: str | None = None - # The handoff lane at rest (scoped/shipped/skipped/cancelled); None while - # the item is in flight. - lane: str | None = None - links: Links - status: SubjectStatus - updated_at: datetime - - @classmethod - def from_work_item(cls, item: WorkItem, status: SubjectStatus) -> "AgentWorkItem": - return cls( - work_item_id=item.id, - title=clip(item.title, _TITLE_CLIP) or "", - source=item.source, - ticket_ref=item.remote_key, - repo=item.repo, - pr_number=item.pr_number, - branch=item.branch, - lane=item.status, - links=Links.from_work_item(item, ticket_clip=_URL_CLIP), - status=_bounded_status(status), - updated_at=item.updated_at, - ) - - -class AgentWorkPage(BaseResponse): - items: list[AgentWorkItem] = Field(default_factory=list) - # Opaque keyset position; absent on the last page. - next_cursor: str | None = None - - -class AgentWorkItemDetail(BaseResponse): - work_item_id: int - title: str - source: str - project_name: str - ticket_ref: str | None = None - repo: str - pr_number: int | None = None - branch: str | None = None - lane: str | None = None - links: Links - status: SubjectStatus - created_at: datetime - updated_at: datetime - # Newest first, each with its latest agent calls. - runs: list[RunSummary] = Field(default_factory=list) - - @classmethod - def from_work_item( - cls, item: WorkItem, status: SubjectStatus, runs: list[RunSummary] - ) -> "AgentWorkItemDetail": - return cls( - work_item_id=item.id, - title=clip(item.title, _TITLE_CLIP) or "", - source=item.source, - project_name=item.project.name, - ticket_ref=item.remote_key, - repo=item.repo, - pr_number=item.pr_number, - branch=item.branch, - lane=item.status, - links=Links.from_work_item(item, ticket_clip=_URL_CLIP), - status=_bounded_status(status), - created_at=item.created_at, - updated_at=item.updated_at, - runs=runs, - ) - - -class AgentDispatchResult(BaseResponse): - work_item_id: int - run_id: str - is_owned_by_caller: bool - note: str diff --git a/backend/tests/test_agent_routes.py b/backend/tests/test_agent_routes.py index 95a32c4..9e83db4 100644 --- a/backend/tests/test_agent_routes.py +++ b/backend/tests/test_agent_routes.py @@ -7,12 +7,9 @@ make_settings, make_test_work_item, seed_build_run, - seed_call, seed_run, ) from druks.accounts.models import Account -from druks.build import agent as build_agent -from druks.build.workflows import BuildWorkflow from druks.durable.models import Run from druks.durable.reads import read_transcript_chunk from druks.mcp.gateway import services @@ -25,9 +22,6 @@ } _AGENT_ROUTES = { - ("get", "/api/build/agent/work"): "list_work", - ("get", "/api/build/agent/work-items/{work_item_id}"): "get_work_item", - ("post", "/api/build/agent/dispatch"): "dispatch", ("get", "/api/agent/gates/{run_id}"): "get_gate", ("post", "/api/agent/gates/{run_id}/answer"): "answer_gate", ("get", "/api/agent/agent-calls/{call_id}"): "get_agent_call", @@ -74,7 +68,7 @@ def _park(db_session, item_id): return run -def test_openapi_pins_the_eight_agent_routes(client: TestClient): +def test_openapi_pins_the_five_agent_routes(client: TestClient): from druks.api.app import app schema = app.openapi() @@ -90,7 +84,6 @@ def test_openapi_pins_the_eight_agent_routes(client: TestClient): def test_agent_routes_sit_behind_the_gate(tmp_path, db_session): app = configure_app_for_test(settings=make_settings(tmp_path), authenticated=False) with TestClient(app) as anonymous: - assert anonymous.get("/api/build/agent/work").status_code == 401 assert anonymous.get("/api/agent/gates/x").status_code == 401 assert anonymous.get("/api/usage/agent").status_code == 401 @@ -104,10 +97,6 @@ def test_agent_errors_share_one_shape(client: TestClient, db_session): "retryable": False, } - bad_cursor = client.get("/api/build/agent/work", params={"cursor": "!!junk!!"}) - assert bad_cursor.status_code == 400 - assert bad_cursor.json()["code"] == "INVALID_CURSOR" - item = make_test_work_item(repo="o/r", title="t") run = _park(db_session, item.id) stale = client.post( @@ -119,10 +108,6 @@ def test_agent_errors_share_one_shape(client: TestClient, db_session): assert body["code"] == "GATE_ROUND_STALE" assert body["retryable"] is True - missing_item = client.get("/api/build/agent/work-items/999999") - assert missing_item.status_code == 404 - assert missing_item.json()["code"] == "WORK_ITEM_NOT_FOUND" - def test_get_gate_then_answer_roundtrip(client: TestClient, db_session, resume_spy): item = make_test_work_item(repo="o/r", title="t") @@ -203,69 +188,6 @@ def test_cancel_run_route(client: TestClient, db_session): assert again.json()["result"] == "already_cancelled" -def test_dispatch_route(client: TestClient, db_session, account, monkeypatch): - item = make_test_work_item(repo="o/r", title="t") - seed_run(db_session, "run-dispatch", account_id=account.id) - - async def _start(cls, **kwargs): - return "run-dispatch" - - monkeypatch.setattr(BuildWorkflow, "start", classmethod(_start)) - - both = client.post( - "/api/build/agent/dispatch", - json={"work_item_id": item.id, "source": "linear", "ticket_ref": "ACME-1"}, - ) - assert both.status_code == 422 - neither = client.post("/api/build/agent/dispatch", json={}) - assert neither.status_code == 422 - half = client.post("/api/build/agent/dispatch", json={"source": "linear"}) - assert half.status_code == 422 - - ok = client.post("/api/build/agent/dispatch", json={"work_item_id": item.id}) - assert ok.status_code == 200 - body = ok.json() - assert body["workItemId"] == item.id - assert body["runId"] == "run-dispatch" - assert body["isOwnedByCaller"] is True - - -def test_work_routes_match_the_services(client: TestClient, db_session, account): - item = make_test_work_item(repo="o/r", title="parity", remote_key="ACME-9") - run = seed_build_run(db_session, work_item_id=item.id, state="running") - seed_call(db_session, run, "implement") - - page = client.get("/api/build/agent/work") - assert page.status_code == 200 - assert page.json() == build_agent.list_work(account).model_dump(mode="json", by_alias=True) - - detail = client.get(f"/api/build/agent/work-items/{item.id}") - assert detail.status_code == 200 - assert detail.json() == build_agent.get_work_item(item.id).model_dump( - mode="json", by_alias=True - ) - - call_id = detail.json()["runs"][0]["agentCalls"][0]["id"] - call = client.get(f"/api/agent/agent-calls/{call_id}") - assert call.status_code == 200 - assert call.json() == services.get_agent_call(call_id).model_dump(mode="json", by_alias=True) - - -def test_board_status_matches_list_work(client: TestClient, db_session, account): - item = make_test_work_item(repo="o/r", title="board parity") - _park(db_session, item.id) - - board = client.get("/api/build/work_item") - assert board.status_code == 200 - row = next(r for r in board.json()["rows"] if r["summary"]["id"] == str(item.id)) - listed = next( - i - for i in build_agent.list_work(account).model_dump(mode="json", by_alias=True)["items"] - if i["workItemId"] == item.id - ) - assert listed["status"] == row["status"] - - def test_transcript_route_matches_the_read_machinery(client: TestClient, db_session, db_engine): from conftest import seed_agent_run diff --git a/backend/tests/test_agent_services.py b/backend/tests/test_agent_services.py index 190fc7a..5e0abee 100644 --- a/backend/tests/test_agent_services.py +++ b/backend/tests/test_agent_services.py @@ -5,9 +5,6 @@ import pytest from conftest import make_test_work_item, seed_build_run, seed_call from druks.accounts.models import Account -from druks.build import agent as build_agent -from druks.build.exceptions import InvalidCursor, WorkItemNotFound -from druks.build.workflows import BuildWorkflow from druks.durable.models import Artifact, Run from druks.durable.reads import read_slice from druks.mcp.gateway import services @@ -402,163 +399,6 @@ async def test_cancel_run_paths(db_session): await services.cancel_run("no-such-run", reason="x") -# ---- work board ----------------------------------------------------------- - - -def test_list_work_filters(db_session, account): - mine_item = make_test_work_item(repo="o/mine", title="mine") - mine_run = seed_build_run(db_session, work_item_id=mine_item.id, state="running") - mine_run.account_id = account.id - parked_item = make_test_work_item(repo="o/parked", title="parked") - _park(db_session, parked_item.id) - failed_item = make_test_work_item(repo="o/failed", title="failed") - seed_build_run(db_session, work_item_id=failed_item.id, state="failed", failure="crash") - db_session.flush() - - assert {i.work_item_id for i in build_agent.list_work(account).items} == { - mine_item.id, - parked_item.id, - failed_item.id, - } - mine = build_agent.list_work(account, filter="mine").items - assert [i.work_item_id for i in mine] == [mine_item.id] - parked = build_agent.list_work(account, filter="parked").items - assert [i.work_item_id for i in parked] == [parked_item.id] - assert parked[0].status.state.value == "pending_input" - assert parked[0].status.gate == "review" - active = build_agent.list_work(account, filter="active").items - assert {i.work_item_id for i in active} == {mine_item.id, parked_item.id} - failed = build_agent.list_work(account, filter="failed").items - assert [i.work_item_id for i in failed] == [failed_item.id] - assert failed[0].status.failure == "crash" - - -def test_list_work_walks_the_keyset_cursor(db_session, account): - for index in range(20): - make_test_work_item(repo=f"o/r{index}", title=f"item {index}") - - first = build_agent.list_work(account) - assert len(first.items) == 12 - assert first.next_cursor - second = build_agent.list_work(account, cursor=first.next_cursor) - assert len(second.items) == 8 - assert second.next_cursor is None - seen = [item.work_item_id for item in [*first.items, *second.items]] - assert len(seen) == len(set(seen)) == 20 - - with pytest.raises(InvalidCursor): - build_agent.list_work(account, cursor="!!not-a-cursor!!") - - -def test_list_work_page_stays_within_budget(db_session, account): - # The budgets bound the SERIALIZED response, so the worst-case page must - # hold for maximally multibyte text and for control bytes that JSON - # escaping blows up six-fold (ANSI color codes in real failures). - for index in range(13): - item = make_test_work_item( - repo=f"o/very-long-repo-name-{index}", - title="🦖" * 400, - remote_url="https://tracker.example.com/" + "x" * 1000, - ) - seed_build_run(db_session, work_item_id=item.id, state="failed", failure="\x1b[31m💥" * 500) - - page = build_agent.list_work(account) - - assert len(page.items) == 12 - for row in page.items: - assert len(json.dumps(row.title, ensure_ascii=False).encode()) - 2 <= 120 - assert len(json.dumps(row.status.failure or "", ensure_ascii=False).encode()) - 2 <= 160 - assert len(page.model_dump_json(by_alias=True).encode()) <= 12 * 1024 - - -def test_get_work_item_detail_stays_within_budget(db_session, account): - item = make_test_work_item( - repo="o/r", - title="🦖" * 400, - remote_key="ACME-1", - remote_url="https://tracker.example.com/" + "x" * 17000, - ) - for _ in range(6): - run = seed_build_run(db_session, work_item_id=item.id, state="finished") - for _ in range(6): - seed_call(db_session, run, "implement", status="failed", last_error="💥" * 1000) - seed_build_run(db_session, work_item_id=item.id, state="failed", failure="💥" * 1000) - - detail = build_agent.get_work_item(item.id) - - assert detail.work_item_id == item.id - assert len(detail.title.encode()) <= 120 - assert len(detail.runs) == 5 - for run_summary in detail.runs: - assert len(run_summary.agent_calls) <= 5 - for call_summary in run_summary.agent_calls: - assert len((call_summary.last_error or "").encode()) <= 160 - assert detail.links.repo == "https://github.com/o/r" - assert len(detail.model_dump_json(by_alias=True).encode()) <= 16 * 1024 - - with pytest.raises(WorkItemNotFound): - build_agent.get_work_item(999999) - - -# ---- dispatch ------------------------------------------------------------- - - -@pytest.fixture -def start_stub(db_session, monkeypatch): - from conftest import seed_run - - async def _start(cls, **kwargs): - if not Run.get("run-dispatch"): - seed_run(db_session, "run-dispatch") - return "run-dispatch" - - monkeypatch.setattr(BuildWorkflow, "start", classmethod(_start)) - - -async def test_dispatch_by_work_item_id(db_session, account, start_stub): - item = make_test_work_item(repo="o/r", title="t") - - result = await build_agent.dispatch(account, work_item_id=item.id) - - assert result.work_item_id == item.id - assert result.run_id == "run-dispatch" - run_row = Run.get("run-dispatch") - assert result.is_owned_by_caller == (run_row.account_id == account.id) - assert item.build_run_id == "run-dispatch" - assert item.status is None - - -async def test_dispatch_by_ticket_ref(db_session, account, start_stub): - item = make_test_work_item(repo="o/r", title="t", source="linear", remote_key="ACME-7") - - result = await build_agent.dispatch(account, source="linear", ticket_ref="ACME-7") - - assert result.work_item_id == item.id - assert result.run_id == "run-dispatch" - - -async def test_dispatch_stamps_ambient_attribution(db_session, account, start_stub): - # The MCP boundary has no session gate, so the service itself must stamp - # the caller before the launch policy runs — start() inherits it when the - # item carries no assignee. - from druks.accounts import sessions - - item = make_test_work_item(repo="o/r", title="t") - token = sessions.current_account_id.set(None) - try: - await build_agent.dispatch(account, work_item_id=item.id) - assert sessions.current_account_id.get() == account.id - finally: - sessions.current_account_id.reset(token) - - -async def test_dispatch_unknown_item(db_session, account, start_stub): - with pytest.raises(WorkItemNotFound): - await build_agent.dispatch(account, work_item_id=424242) - with pytest.raises(WorkItemNotFound): - await build_agent.dispatch(account, source="jira", ticket_ref="NOPE-1") - - # ---- usage ---------------------------------------------------------------- From 9f0afe656299bf68221b1ccee1b6e6a31310e947 Mon Sep 17 00:00:00 2001 From: Paulo Date: Mon, 20 Jul 2026 01:51:51 +0200 Subject: [PATCH 06/21] Name the gate response GateDetail and move AnswerGateRequest to schemas GateView collided with Django's View (the request handler) and was the lone *View among response DTOs that otherwise read Response/Result/Detail/Status; GateDetail matches its sibling AgentCallDetail. The request model moves from the route file to schemas.py, where request bodies live. --- backend/druks/mcp/gateway/routes.py | 21 ++++----------------- backend/druks/mcp/gateway/schemas.py | 17 +++++++++++++++-- backend/druks/mcp/gateway/services.py | 6 +++--- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/backend/druks/mcp/gateway/routes.py b/backend/druks/mcp/gateway/routes.py index 71d3959..0a1c146 100644 --- a/backend/druks/mcp/gateway/routes.py +++ b/backend/druks/mcp/gateway/routes.py @@ -1,8 +1,6 @@ from typing import Annotated from fastapi import APIRouter, Body, Depends -from pydantic import AwareDatetime, BaseModel, ConfigDict, Field -from pydantic.alias_generators import to_camel from druks.accounts.dependencies import current_account from druks.accounts.models import Account @@ -10,33 +8,22 @@ from druks.mcp.gateway.schemas import ( AgentCallDetail, AgentUsage, + AnswerGateRequest, CancelRunResult, GateAnswerResult, - GateView, + GateDetail, ) router = APIRouter(tags=["agent"]) -class AnswerGateRequest(BaseModel): - # parkedAt echoes get_gate's response key unchanged — the park identity the - # answer must land on; one camelCase wire both directions. The rest mirrors - # ResumeRequest: a control the ask offered, an answer per open question, an - # optional free-text note. - model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel) - parked_at: AwareDatetime - control: str - answers: dict[str, str] = Field(default_factory=dict) - note: str = "" - - @router.get( "/api/agent/gates/{run_id}", operation_id="get_gate", - response_model=GateView, + response_model=GateDetail, response_model_by_alias=True, ) -async def get_gate(run_id: str) -> GateView: +async def get_gate(run_id: str) -> GateDetail: return services.get_gate(run_id) diff --git a/backend/druks/mcp/gateway/schemas.py b/backend/druks/mcp/gateway/schemas.py index 6a5cd56..2c8bc0a 100644 --- a/backend/druks/mcp/gateway/schemas.py +++ b/backend/druks/mcp/gateway/schemas.py @@ -1,7 +1,8 @@ from datetime import datetime from typing import Any, Literal -from pydantic import Field +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel from druks.durable.schemas import AgentCallSummary, TextSlice from druks.schemas import BaseResponse @@ -17,7 +18,7 @@ class ArtifactChunk(BaseResponse): chunk: TextSlice -class GateView(BaseResponse): +class GateDetail(BaseResponse): # Everything needed to answer a parked run in one read: the ask, the # artifact under review, the reply's JSON Schema, and parked_at — the park # identity answer_gate must echo back. @@ -35,6 +36,18 @@ class GateAnswerResult(BaseResponse): result: Literal["answered", "already_answered"] +class AnswerGateRequest(BaseModel): + # parkedAt echoes get_gate's response key unchanged — the park identity the + # answer must land on; one camelCase wire both directions. The rest mirrors + # ResumeRequest: a control the ask offered, an answer per open question, an + # optional free-text note. + model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel) + parked_at: AwareDatetime + control: str + answers: dict[str, str] = Field(default_factory=dict) + note: str = "" + + class AgentCallDetail(BaseResponse): run_id: str call: AgentCallSummary diff --git a/backend/druks/mcp/gateway/services.py b/backend/druks/mcp/gateway/services.py index 8e0d5b4..67cc8aa 100644 --- a/backend/druks/mcp/gateway/services.py +++ b/backend/druks/mcp/gateway/services.py @@ -30,7 +30,7 @@ ArtifactChunk, CancelRunResult, GateAnswerResult, - GateView, + GateDetail, ) from druks.notifications.exceptions import InvalidChoiceError from druks.notifications.services import validate_in_app_answer @@ -71,7 +71,7 @@ def _bounded_ask(ask: dict[str, Any]) -> dict[str, Any]: return {**ask, "questions": questions} -def get_gate(run_id: str) -> GateView: +def get_gate(run_id: str) -> GateDetail: run = Run.get(run_id) if not run: raise RunNotFound(run_id) @@ -83,7 +83,7 @@ def get_gate(run_id: str) -> GateView: # comment); an ask-less park offers no reply contract either. raise GateNotAnswerable(run_id) ask = _bounded_ask(run.get_ask()) - return GateView( + return GateDetail( run_id=run.id, gate=run.input_gate, # type: ignore[arg-type] parked_at=run.input_requested_at, # type: ignore[arg-type] From 951b8e9c322c3c90454e0d60a5cdd00c11e36bc3 Mon Sep 17 00:00:00 2001 From: Paulo Date: Mon, 20 Jul 2026 11:28:04 +0200 Subject: [PATCH 07/21] Split the trend downsampler out of usage reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reads.py held a query and a pure transform, so the name only half-fit. downsample (which reads nothing) and its window ranges move to usage/trends.py; list_finished_calls_today stays, and reads.py is now only reads. The agent history cap moves into the gateway that owns it — the dashboard keeps its own — dropping the private cross-module import. --- backend/druks/mcp/gateway/services.py | 11 ++++------- backend/druks/usage/reads.py | 28 +++------------------------ backend/druks/usage/routes.py | 3 ++- backend/druks/usage/trends.py | 20 +++++++++++++++++++ 4 files changed, 29 insertions(+), 33 deletions(-) create mode 100644 backend/druks/usage/trends.py diff --git a/backend/druks/mcp/gateway/services.py b/backend/druks/mcp/gateway/services.py index 67cc8aa..cda0f10 100644 --- a/backend/druks/mcp/gateway/services.py +++ b/backend/druks/mcp/gateway/services.py @@ -36,14 +36,9 @@ from druks.notifications.services import validate_in_app_answer from druks.schemas import clip from druks.usage.models import UsageScrape -from druks.usage.reads import ( - _HISTORY_POINTS, - FIVE_HOUR_RANGE, - WEEK_RANGE, - downsample, - list_finished_calls_today, -) +from druks.usage.reads import list_finished_calls_today from druks.usage.schemas import UsageHistoryPoint +from druks.usage.trends import FIVE_HOUR_RANGE, WEEK_RANGE, downsample _TRANSCRIPT_TAIL_BYTES = 8 * 1024 _STDERR_TAIL_BYTES = 4 * 1024 @@ -51,6 +46,8 @@ _NOTE_BYTES = 2 * 1024 _PROMPT_CLIP = 2 * 1024 _OPTION_LABEL_CLIP = 256 +# Each usage trend stays this short so the whole response holds its byte budget. +_HISTORY_POINTS = 8 def _bounded_ask(ask: dict[str, Any]) -> dict[str, Any]: diff --git a/backend/druks/usage/reads.py b/backend/druks/usage/reads.py index 7b7aa24..91a81b6 100644 --- a/backend/druks/usage/reads.py +++ b/backend/druks/usage/reads.py @@ -1,6 +1,6 @@ -# Usage's shared read helpers: the operator-local day window over finished -# calls and the trend downsampler. The dashboard routes and the agent usage -# service both read through here so every spend-today figure stays identical. +# Usage's shared read: an account's finished calls in the operator-local day. +# The dashboard routes and the agent usage service both read through here so +# every spend-today figure stays identical. from datetime import UTC, datetime, timedelta from zoneinfo import ZoneInfo @@ -9,19 +9,8 @@ from druks.core.utils.time import operator_local_day from druks.database import db_session from druks.durable.models import AgentCall -from druks.usage.schemas import UsageHistoryPoint from druks.user_settings.models import UserSettings -# Trend ranges for the percent-left sparklines. The 5h window gets one full -# window plus headroom so an exhaustion arc is visible end to end; weekly gets -# the whole week. -FIVE_HOUR_RANGE = timedelta(hours=6) -WEEK_RANGE = timedelta(days=7) - -# The agent read keeps each trend this short so the whole response stays -# within its byte budget. -_HISTORY_POINTS = 8 - def list_finished_calls_today(account_id: str) -> tuple[ZoneInfo, datetime, list[Row]]: # The account's finished calls in the operator-local day, as (timezone, @@ -45,14 +34,3 @@ def list_finished_calls_today(account_id: str) -> tuple[ZoneInfo, datetime, list .all() ) return timezone, local_start, rows - - -def downsample(points: list[UsageHistoryPoint], *, cap: int) -> list[UsageHistoryPoint]: - # Thin a series to ≤ cap points, always keeping the newest sample (the - # "now" anchor) — it replaces the last strided sample so the cap holds. - if len(points) <= cap: - return points - stride = -(-len(points) // cap) # ceil division - thinned = points[::stride] - thinned[-1] = points[-1] - return thinned diff --git a/backend/druks/usage/routes.py b/backend/druks/usage/routes.py index f77d0f5..86dc415 100644 --- a/backend/druks/usage/routes.py +++ b/backend/druks/usage/routes.py @@ -8,7 +8,7 @@ from druks.harnesses.models import HarnessConnection from druks.harnesses.registry import get_harnesses from druks.usage.models import UsageScrape -from druks.usage.reads import FIVE_HOUR_RANGE, WEEK_RANGE, downsample, list_finished_calls_today +from druks.usage.reads import list_finished_calls_today from druks.usage.schemas import ( UsageHarnessHistory, UsageHarnessSummary, @@ -19,6 +19,7 @@ UsageResponse, UsageTodayResponse, ) +from druks.usage.trends import FIVE_HOUR_RANGE, WEEK_RANGE, downsample from druks.user_settings.models import HarnessSettings router = APIRouter(tags=["usage"]) diff --git a/backend/druks/usage/trends.py b/backend/druks/usage/trends.py new file mode 100644 index 0000000..6b12b23 --- /dev/null +++ b/backend/druks/usage/trends.py @@ -0,0 +1,20 @@ +from datetime import timedelta + +from druks.usage.schemas import UsageHistoryPoint + +# Trend ranges for the percent-left sparklines. The 5h window gets one full +# window plus headroom so an exhaustion arc is visible end to end; weekly gets +# the whole week. +FIVE_HOUR_RANGE = timedelta(hours=6) +WEEK_RANGE = timedelta(days=7) + + +def downsample(points: list[UsageHistoryPoint], *, cap: int) -> list[UsageHistoryPoint]: + # Thin a series to ≤ cap points, always keeping the newest sample (the + # "now" anchor) — it replaces the last strided sample so the cap holds. + if len(points) <= cap: + return points + stride = -(-len(points) // cap) # ceil division + thinned = points[::stride] + thinned[-1] = points[-1] + return thinned From 22cd24a265790c67d906bc947df0517362f9abe7 Mon Sep 17 00:00:00 2001 From: Paulo Date: Mon, 20 Jul 2026 11:37:30 +0200 Subject: [PATCH 08/21] Make the finished-calls read take its window instead of deriving the day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list_finished_calls_today computed the operator-local day from UserSettings and returned timezone + local_start beside the rows — settings/display policy mixed into a query. It's now list_finished_calls(account_id, *, since, until): a pure window read returning only rows. The two callers derive the operator-day window themselves, which they already need for the day label and hour bucketing. --- backend/druks/mcp/gateway/services.py | 9 ++++++--- backend/druks/usage/reads.py | 24 ++++++++---------------- backend/druks/usage/routes.py | 14 ++++++++------ 3 files changed, 22 insertions(+), 25 deletions(-) diff --git a/backend/druks/mcp/gateway/services.py b/backend/druks/mcp/gateway/services.py index cda0f10..e581995 100644 --- a/backend/druks/mcp/gateway/services.py +++ b/backend/druks/mcp/gateway/services.py @@ -2,10 +2,11 @@ # read/answer and cancel on runs, bounded agent-call detail, and the caller's # quota and spend — served to the /api/agent and /api/usage/agent routes and the # MCP tools. -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from typing import Any from druks.accounts.models import Account +from druks.core.utils.time import operator_local_day from druks.database import db_session from druks.durable.enums import RunState from druks.durable.models import AgentCall, Artifact, Run @@ -36,9 +37,10 @@ from druks.notifications.services import validate_in_app_answer from druks.schemas import clip from druks.usage.models import UsageScrape -from druks.usage.reads import list_finished_calls_today +from druks.usage.reads import list_finished_calls from druks.usage.schemas import UsageHistoryPoint from druks.usage.trends import FIVE_HOUR_RANGE, WEEK_RANGE, downsample +from druks.user_settings.models import UserSettings _TRANSCRIPT_TAIL_BYTES = 8 * 1024 _STDERR_TAIL_BYTES = 4 * 1024 @@ -199,7 +201,8 @@ def _reply_schema(ask: dict[str, Any]) -> dict[str, Any]: def get_usage(account: Account) -> AgentUsage: now = datetime.now(UTC) - timezone, local_start, rows = list_finished_calls_today(account.id) + timezone, local_start = operator_local_day(UserSettings.get().timezone, now) + rows = list_finished_calls(account.id, since=local_start, until=local_start + timedelta(days=1)) spend = 0.0 tokens = 0 for _, cost_usd, cost_metadata, _ in rows: diff --git a/backend/druks/usage/reads.py b/backend/druks/usage/reads.py index 91a81b6..973d5ad 100644 --- a/backend/druks/usage/reads.py +++ b/backend/druks/usage/reads.py @@ -1,23 +1,16 @@ -# Usage's shared read: an account's finished calls in the operator-local day. -# The dashboard routes and the agent usage service both read through here so -# every spend-today figure stays identical. -from datetime import UTC, datetime, timedelta -from zoneinfo import ZoneInfo +# Usage's shared read: an account's finished calls in a time window, as rows of +# (model, cost_usd, cost_metadata, finished_at). The caller passes the +# operator-local-day window, so every spend-today figure counts the same calls. +from datetime import datetime from sqlalchemy import Row, select -from druks.core.utils.time import operator_local_day from druks.database import db_session from druks.durable.models import AgentCall -from druks.user_settings.models import UserSettings -def list_finished_calls_today(account_id: str) -> tuple[ZoneInfo, datetime, list[Row]]: - # The account's finished calls in the operator-local day, as (timezone, - # local_start, rows of (model, cost_usd, cost_metadata, finished_at)) — - # the shared boundary keeps every spend-today figure identical. - timezone, local_start = operator_local_day(UserSettings.get().timezone, datetime.now(UTC)) - rows = ( +def list_finished_calls(account_id: str, *, since: datetime, until: datetime) -> list[Row]: + return list( db_session() .execute( select( @@ -28,9 +21,8 @@ def list_finished_calls_today(account_id: str) -> tuple[ZoneInfo, datetime, list ) .where(AgentCall.account_id == account_id) .where(AgentCall.finished_at.is_not(None)) - .where(AgentCall.finished_at >= local_start.astimezone(UTC)) - .where(AgentCall.finished_at < (local_start + timedelta(days=1)).astimezone(UTC)), + .where(AgentCall.finished_at >= since) + .where(AgentCall.finished_at < until) ) .all() ) - return timezone, local_start, rows diff --git a/backend/druks/usage/routes.py b/backend/druks/usage/routes.py index 86dc415..1ae11a5 100644 --- a/backend/druks/usage/routes.py +++ b/backend/druks/usage/routes.py @@ -1,14 +1,15 @@ -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from fastapi import APIRouter, Depends from druks.accounts.dependencies import current_account from druks.accounts.models import Account +from druks.core.utils.time import operator_local_day from druks.harnesses.artifacts import normalize_token_usage from druks.harnesses.models import HarnessConnection from druks.harnesses.registry import get_harnesses from druks.usage.models import UsageScrape -from druks.usage.reads import list_finished_calls_today +from druks.usage.reads import list_finished_calls from druks.usage.schemas import ( UsageHarnessHistory, UsageHarnessSummary, @@ -20,7 +21,7 @@ UsageTodayResponse, ) from druks.usage.trends import FIVE_HOUR_RANGE, WEEK_RANGE, downsample -from druks.user_settings.models import HarnessSettings +from druks.user_settings.models import HarnessSettings, UserSettings router = APIRouter(tags=["usage"]) @@ -89,9 +90,10 @@ async def get_usage_history(account: Account = Depends(current_account)) -> Usag response_model_by_alias=True, ) async def get_usage_today(account: Account = Depends(current_account)) -> UsageTodayResponse: - # The shared operator-local-day read keeps this total identical to the - # sys-strip's and the agent surface's spend-today figures. - timezone, local_start, rows = list_finished_calls_today(account.id) + # Deriving the operator-local-day window here (the query just takes it) keeps + # this total identical to the sys-strip's and the agent surface's figures. + timezone, local_start = operator_local_day(UserSettings.get().timezone, datetime.now(UTC)) + rows = list_finished_calls(account.id, since=local_start, until=local_start + timedelta(days=1)) timezone_name = str(timezone) # Every call counts, even one whose model no picker list claims (pinned From 2c5e73422517bd55e9f73fe1d4297fa1e83b3768 Mon Sep 17 00:00:00 2001 From: Paulo Date: Mon, 20 Jul 2026 11:43:42 +0200 Subject: [PATCH 09/21] Serve the gateway under /mcp, its own entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The routes carried the original spec's /api/agent and /api/usage/agent paths, but the surface is the MCP gateway — so it gets its own top-level prefix like /api, tagged mcp. get_gate → /mcp/gates/{run_id}, answer → .../answer, get_agent_call → /mcp/agent-calls/{call_id}, cancel_run → /mcp/runs/{run_id}/cancel, get_usage → /mcp/usage. Bearer-first auth (PAT or session) unchanged. --- backend/druks/api/app.py | 4 +-- backend/druks/mcp/gateway/routes.py | 12 ++++---- backend/druks/mcp/gateway/services.py | 3 +- backend/tests/test_agent_routes.py | 42 +++++++++++++-------------- 4 files changed, 30 insertions(+), 31 deletions(-) diff --git a/backend/druks/api/app.py b/backend/druks/api/app.py index bc59dc0..0795815 100644 --- a/backend/druks/api/app.py +++ b/backend/druks/api/app.py @@ -19,7 +19,7 @@ from druks.extensions.loader import iter_extensions, load from druks.mcp.catalog import load_mcp_catalog from druks.mcp.gateway.exceptions import AgentApiError -from druks.mcp.gateway.routes import router as agent_router +from druks.mcp.gateway.routes import router as gateway_router from druks.mcp.routes import router as mcp_router from druks.notifications.routes import external_router as notifications_external_router from druks.notifications.routes import router as notifications_router @@ -197,7 +197,7 @@ async def _unhandled_exception_handler( app.include_router(notifications_router, dependencies=_session_gate) app.include_router(events_router, dependencies=_session_gate) app.include_router(runs_router, dependencies=_session_gate) -app.include_router(agent_router, dependencies=_session_gate) +app.include_router(gateway_router, dependencies=_session_gate) app.include_router(artifacts_router, dependencies=_session_gate) load(app) diff --git a/backend/druks/mcp/gateway/routes.py b/backend/druks/mcp/gateway/routes.py index 0a1c146..2662441 100644 --- a/backend/druks/mcp/gateway/routes.py +++ b/backend/druks/mcp/gateway/routes.py @@ -14,11 +14,11 @@ GateDetail, ) -router = APIRouter(tags=["agent"]) +router = APIRouter(prefix="/mcp", tags=["mcp"]) @router.get( - "/api/agent/gates/{run_id}", + "/gates/{run_id}", operation_id="get_gate", response_model=GateDetail, response_model_by_alias=True, @@ -28,7 +28,7 @@ async def get_gate(run_id: str) -> GateDetail: @router.post( - "/api/agent/gates/{run_id}/answer", + "/gates/{run_id}/answer", operation_id="answer_gate", response_model=GateAnswerResult, response_model_by_alias=True, @@ -44,7 +44,7 @@ async def answer_gate(run_id: str, body: AnswerGateRequest) -> GateAnswerResult: @router.get( - "/api/agent/agent-calls/{call_id}", + "/agent-calls/{call_id}", operation_id="get_agent_call", response_model=AgentCallDetail, response_model_by_alias=True, @@ -54,7 +54,7 @@ async def get_agent_call(call_id: str) -> AgentCallDetail: @router.post( - "/api/agent/runs/{run_id}/cancel", + "/runs/{run_id}/cancel", operation_id="cancel_run", response_model=CancelRunResult, response_model_by_alias=True, @@ -66,7 +66,7 @@ async def cancel_run( @router.get( - "/api/usage/agent", + "/usage", operation_id="get_usage", response_model=AgentUsage, response_model_by_alias=True, diff --git a/backend/druks/mcp/gateway/services.py b/backend/druks/mcp/gateway/services.py index e581995..417c536 100644 --- a/backend/druks/mcp/gateway/services.py +++ b/backend/druks/mcp/gateway/services.py @@ -1,7 +1,6 @@ # The inbound agent gateway's platform operations over shared primitives: gate # read/answer and cancel on runs, bounded agent-call detail, and the caller's -# quota and spend — served to the /api/agent and /api/usage/agent routes and the -# MCP tools. +# quota and spend — served under the /mcp routes and, in T4, the MCP tools. from datetime import UTC, datetime, timedelta from typing import Any diff --git a/backend/tests/test_agent_routes.py b/backend/tests/test_agent_routes.py index 9e83db4..7ac43a2 100644 --- a/backend/tests/test_agent_routes.py +++ b/backend/tests/test_agent_routes.py @@ -21,12 +21,12 @@ "questions": [], } -_AGENT_ROUTES = { - ("get", "/api/agent/gates/{run_id}"): "get_gate", - ("post", "/api/agent/gates/{run_id}/answer"): "answer_gate", - ("get", "/api/agent/agent-calls/{call_id}"): "get_agent_call", - ("post", "/api/agent/runs/{run_id}/cancel"): "cancel_run", - ("get", "/api/usage/agent"): "get_usage", +_MCP_ROUTES = { + ("get", "/mcp/gates/{run_id}"): "get_gate", + ("post", "/mcp/gates/{run_id}/answer"): "answer_gate", + ("get", "/mcp/agent-calls/{call_id}"): "get_agent_call", + ("post", "/mcp/runs/{run_id}/cancel"): "cancel_run", + ("get", "/mcp/usage"): "get_usage", } @@ -76,20 +76,20 @@ def test_openapi_pins_the_five_agent_routes(client: TestClient): (method, path): operation for path, operations in schema["paths"].items() for method, operation in operations.items() - if operation.get("tags") == ["agent"] + if operation.get("tags") == ["mcp"] } - assert {key: op["operationId"] for key, op in found.items()} == _AGENT_ROUTES + assert {key: op["operationId"] for key, op in found.items()} == _MCP_ROUTES def test_agent_routes_sit_behind_the_gate(tmp_path, db_session): app = configure_app_for_test(settings=make_settings(tmp_path), authenticated=False) with TestClient(app) as anonymous: - assert anonymous.get("/api/agent/gates/x").status_code == 401 - assert anonymous.get("/api/usage/agent").status_code == 401 + assert anonymous.get("/mcp/gates/x").status_code == 401 + assert anonymous.get("/mcp/usage").status_code == 401 def test_agent_errors_share_one_shape(client: TestClient, db_session): - missing = client.get("/api/agent/gates/no-such-run") + missing = client.get("/mcp/gates/no-such-run") assert missing.status_code == 404 assert missing.json() == { "code": "RUN_NOT_FOUND", @@ -100,7 +100,7 @@ def test_agent_errors_share_one_shape(client: TestClient, db_session): item = make_test_work_item(repo="o/r", title="t") run = _park(db_session, item.id) stale = client.post( - f"/api/agent/gates/{run.id}/answer", + f"/mcp/gates/{run.id}/answer", json={"parkedAt": "2020-01-01T00:00:00+00:00", "control": "approve"}, ) assert stale.status_code == 409 @@ -113,13 +113,13 @@ def test_get_gate_then_answer_roundtrip(client: TestClient, db_session, resume_s item = make_test_work_item(repo="o/r", title="t") run = _park(db_session, item.id) - view = client.get(f"/api/agent/gates/{run.id}") + view = client.get(f"/mcp/gates/{run.id}") assert view.status_code == 200 data = view.json() assert data == services.get_gate(run.id).model_dump(mode="json", by_alias=True) answered = client.post( - f"/api/agent/gates/{run.id}/answer", + f"/mcp/gates/{run.id}/answer", json={"parkedAt": data["parkedAt"], "control": "approve", "note": "ship it"}, ) assert answered.status_code == 200 @@ -138,7 +138,7 @@ def test_answer_gate_reads_already_answered_off_the_receipt( db_session.flush() response = client.post( - f"/api/agent/gates/{run.id}/answer", + f"/mcp/gates/{run.id}/answer", json={"parkedAt": parked_at.isoformat(), "control": "approve"}, ) @@ -152,7 +152,7 @@ def test_answer_gate_requires_an_aware_parked_at(client: TestClient, db_session) run = _park(db_session, item.id) naive = client.post( - f"/api/agent/gates/{run.id}/answer", + f"/mcp/gates/{run.id}/answer", json={"parkedAt": "2026-07-19T10:00:00", "control": "approve"}, ) @@ -168,12 +168,12 @@ def test_cancel_run_route(client: TestClient, db_session): run.input_requested_at = run.utc_now() db_session.flush() - unbounded = client.post(f"/api/agent/runs/{run.id}/cancel", json={"reason": "r" * 501}) + unbounded = client.post(f"/mcp/runs/{run.id}/cancel", json={"reason": "r" * 501}) assert unbounded.status_code == 422 - blank = client.post(f"/api/agent/runs/{run.id}/cancel", json={"reason": ""}) + blank = client.post(f"/mcp/runs/{run.id}/cancel", json={"reason": ""}) assert blank.status_code == 422 - cancelled = client.post(f"/api/agent/runs/{run.id}/cancel", json={"reason": "wrong branch"}) + cancelled = client.post(f"/mcp/runs/{run.id}/cancel", json={"reason": "wrong branch"}) assert cancelled.status_code == 200 assert cancelled.json() == {"runId": run.id, "result": "cancelled"} @@ -183,7 +183,7 @@ def test_cancel_run_route(client: TestClient, db_session): assert not run.input_gate assert run.failure == "wrong branch" - again = client.post(f"/api/agent/runs/{run.id}/cancel", json={"reason": "wrong branch"}) + again = client.post(f"/mcp/runs/{run.id}/cancel", json={"reason": "wrong branch"}) assert again.status_code == 200 assert again.json()["result"] == "already_cancelled" @@ -257,7 +257,7 @@ def test_usage_agent_route_matches_the_service(client: TestClient, db_session, a ) db_session.flush() - response = client.get("/api/usage/agent") + response = client.get("/mcp/usage") assert response.status_code == 200 body = response.json() assert body == services.get_usage(account).model_dump(mode="json", by_alias=True) From c87e041fd58fc0e744d012ee70e9c0ae9ad53419 Mon Sep 17 00:00:00 2001 From: Paulo Date: Mon, 20 Jul 2026 11:47:00 +0200 Subject: [PATCH 10/21] Rename the gate receipt column to answer_parked_at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit answered_parked_at stacked the participle against parked_at and read oddly; answer_parked_at is the parked_at an answer landed on, and matches the domain verb (answer_gate / AnswerGateRequest / answers). Internal column only — the wire stays parked_at — and unshipped, so the migration is edited in place. --- backend/druks/durable/models.py | 2 +- backend/druks/mcp/gateway/services.py | 2 +- backend/druks/workflows.py | 2 +- .../migrations/versions/ff43df27a2e0_gate_answer_receipt.py | 4 ++-- backend/tests/test_agent_routes.py | 6 +++--- backend/tests/test_agent_services.py | 2 +- backend/tests/test_gate_receipt.py | 6 +++--- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/backend/druks/durable/models.py b/backend/druks/durable/models.py index c0d0b86..2ddb52c 100644 --- a/backend/druks/durable/models.py +++ b/backend/druks/durable/models.py @@ -45,7 +45,7 @@ class Run(Base): # the answer path writes it (cancel and timeout never do), so a late # duplicate answer reads "already answered" instead of "not open". Wire # name: parked_at. - answered_parked_at: Mapped[datetime | None] = mapped_column(default=None) + answer_parked_at: Mapped[datetime | None] = mapped_column(default=None) failure: Mapped[str | None] = mapped_column(default=None) # The FatalError subtype's code when the run stopped on a deliberate domain # error, so read-sides tell e.g. a gate timeout from a crash without parsing diff --git a/backend/druks/mcp/gateway/services.py b/backend/druks/mcp/gateway/services.py index 417c536..dbab50a 100644 --- a/backend/druks/mcp/gateway/services.py +++ b/backend/druks/mcp/gateway/services.py @@ -100,7 +100,7 @@ async def answer_gate( # Same freshness discipline as notifications/services.py: the answer must # land on the run's live park, so the comparison reads fresh. db_session().expire(run) - if run.answered_parked_at == parked_at: + if run.answer_parked_at == parked_at: # The receipt names the parked_at an answer already resumed — a late # or duplicate answer collapses here instead of reading "not open". return GateAnswerResult(run_id=run.id, parked_at=parked_at, result="already_answered") diff --git a/backend/druks/workflows.py b/backend/druks/workflows.py index 254c7dc..f8dcc83 100644 --- a/backend/druks/workflows.py +++ b/backend/druks/workflows.py @@ -244,7 +244,7 @@ async def _park( workflow.workflow_id, RunState.RUNNING, subject=workflow.subject, - facts={**_GATE_CLEARED, "answered_parked_at": Run.input_requested_at}, + facts={**_GATE_CLEARED, "answer_parked_at": Run.input_requested_at}, ) return payload diff --git a/backend/migrations/versions/ff43df27a2e0_gate_answer_receipt.py b/backend/migrations/versions/ff43df27a2e0_gate_answer_receipt.py index 2db5b78..366ac5a 100644 --- a/backend/migrations/versions/ff43df27a2e0_gate_answer_receipt.py +++ b/backend/migrations/versions/ff43df27a2e0_gate_answer_receipt.py @@ -21,9 +21,9 @@ def upgrade() -> None: op.add_column( "durable_runs", - sa.Column("answered_parked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("answer_parked_at", sa.DateTime(timezone=True), nullable=True), ) def downgrade() -> None: - op.drop_column("durable_runs", "answered_parked_at") + op.drop_column("durable_runs", "answer_parked_at") diff --git a/backend/tests/test_agent_routes.py b/backend/tests/test_agent_routes.py index 7ac43a2..175c5e0 100644 --- a/backend/tests/test_agent_routes.py +++ b/backend/tests/test_agent_routes.py @@ -134,7 +134,7 @@ def test_answer_gate_reads_already_answered_off_the_receipt( parked_at = datetime.now(UTC) run = seed_build_run(db_session, work_item_id=item.id, state="running") run.input_requested_at = parked_at - run.answered_parked_at = parked_at + run.answer_parked_at = parked_at db_session.flush() response = client.post( @@ -179,7 +179,7 @@ def test_cancel_run_route(client: TestClient, db_session): db_session.expire_all() run = db_session.get(type(run), run.id) - assert not run.answered_parked_at + assert not run.answer_parked_at assert not run.input_gate assert run.failure == "wrong branch" @@ -230,7 +230,7 @@ def test_resume_route_contract_is_preserved(client: TestClient, db_session, resu # Once the answer has landed (receipt written, gate cleared), the # dashboard's double-submit stays the conflict it has always been. - run.answered_parked_at = run.input_requested_at + run.answer_parked_at = run.input_requested_at run.input_gate = None run.input_request = None db_session.flush() diff --git a/backend/tests/test_agent_services.py b/backend/tests/test_agent_services.py index 5e0abee..02957e9 100644 --- a/backend/tests/test_agent_services.py +++ b/backend/tests/test_agent_services.py @@ -263,7 +263,7 @@ async def test_answer_gate_uses_the_receipt_for_already_answered(db_session, res parked_at = datetime.now(UTC) run = seed_build_run(db_session, work_item_id=item.id, state="running") run.input_requested_at = parked_at - run.answered_parked_at = parked_at + run.answer_parked_at = parked_at db_session.flush() result = await services.answer_gate( diff --git a/backend/tests/test_gate_receipt.py b/backend/tests/test_gate_receipt.py index afec305..c9f26d2 100644 --- a/backend/tests/test_gate_receipt.py +++ b/backend/tests/test_gate_receipt.py @@ -52,7 +52,7 @@ async def _answer(topic, timeout_seconds): # The receipt is the round the answer cleared: the same stamp the park # wrote, which _GATE_CLEARED preserves on the row. assert run.input_requested_at - assert run.answered_parked_at == run.input_requested_at + assert run.answer_parked_at == run.input_requested_at assert not run.input_gate assert not run.input_request @@ -68,7 +68,7 @@ async def _lapse(topic, timeout_seconds): await _park(_ParkedWorkflow(run.id), "review", _ASK, ttl_seconds=1.0) run = _reload(db_session, run.id) - assert not run.answered_parked_at + assert not run.answer_parked_at assert run.input_requested_at @@ -83,4 +83,4 @@ async def _cancelled(topic, timeout_seconds): await _park(_ParkedWorkflow(run.id), "review", _ASK, ttl_seconds=1.0) run = _reload(db_session, run.id) - assert not run.answered_parked_at + assert not run.answer_parked_at From d650d70e928aad159bf7b9079b993c5256d13f77 Mon Sep 17 00:00:00 2001 From: Paulo Date: Mon, 20 Jul 2026 11:59:30 +0200 Subject: [PATCH 11/21] Delete narration comments the code already states --- backend/druks/durable/models.py | 5 +---- backend/druks/usage/reads.py | 3 --- backend/druks/workflows.py | 5 ----- 3 files changed, 1 insertion(+), 12 deletions(-) diff --git a/backend/druks/durable/models.py b/backend/druks/durable/models.py index 2ddb52c..893dd2d 100644 --- a/backend/druks/durable/models.py +++ b/backend/druks/durable/models.py @@ -41,10 +41,7 @@ class Run(Base): # When the run last asked for input — a historical fact (input_gate says # whether it's still waiting), and the one transition DBOS doesn't stamp. input_requested_at: Mapped[datetime | None] = mapped_column(default=None) - # The receipt: the input_requested_at stamp an answer last resumed. Only - # the answer path writes it (cancel and timeout never do), so a late - # duplicate answer reads "already answered" instead of "not open". Wire - # name: parked_at. + # The receipt: the input_requested_at stamp an answer last resumed. answer_parked_at: Mapped[datetime | None] = mapped_column(default=None) failure: Mapped[str | None] = mapped_column(default=None) # The FatalError subtype's code when the run stopped on a deliberate domain diff --git a/backend/druks/usage/reads.py b/backend/druks/usage/reads.py index 973d5ad..9f56e5a 100644 --- a/backend/druks/usage/reads.py +++ b/backend/druks/usage/reads.py @@ -1,6 +1,3 @@ -# Usage's shared read: an account's finished calls in a time window, as rows of -# (model, cost_usd, cost_metadata, finished_at). The caller passes the -# operator-local-day window, so every spend-today figure counts the same calls. from datetime import datetime from sqlalchemy import Row, select diff --git a/backend/druks/workflows.py b/backend/druks/workflows.py index f8dcc83..8c991a8 100644 --- a/backend/druks/workflows.py +++ b/backend/druks/workflows.py @@ -235,11 +235,6 @@ async def _park( payload = await DBOS.recv_async(topic, timeout_seconds=ttl_seconds) if payload is None: raise GateTimeout(topic) - # Only this path — an answer landing — writes the receipt. The value is the - # row's own parked stamp, copied SQL-side inside the memoized transition - # step: a body-local timestamp would drift from the stored stamp when a - # crash replays this stretch. Timeout raises above; a cancel raises out of - # recv; the FAILED emit clears the gate without the receipt. await _emit_run_event( workflow.workflow_id, RunState.RUNNING, From edb907be3a1ac1003e31dd1acb7c959713eac053 Mon Sep 17 00:00:00 2001 From: Paulo Date: Mon, 20 Jul 2026 11:59:30 +0200 Subject: [PATCH 12/21] Collapse read_slice onto the stdlib UTF-8 decoder The hand-rolled _partial_suffix + _FIRST_CONTINUATION table reimplemented RFC 3629 validity that Python's own decoder already knows. _complete_prefix trims only a truncated trailing sequence (reason 'unexpected end of data'); a genuinely invalid byte is kept and replaced on decode, so pagination never stalls. Same behavior, far less code. --- backend/druks/durable/reads.py | 55 +++++++++------------------------- 1 file changed, 14 insertions(+), 41 deletions(-) diff --git a/backend/druks/durable/reads.py b/backend/druks/durable/reads.py index f29f7b5..5fe3e89 100644 --- a/backend/druks/durable/reads.py +++ b/backend/druks/durable/reads.py @@ -134,48 +134,25 @@ def _continuation_prefix(payload: bytes) -> int: return length -# Leads whose first continuation is narrower than 80–BF (RFC 3629): E0/F0 -# exclude overlongs, ED excludes surrogates, F4 caps at U+10FFFF. -_FIRST_CONTINUATION = { - 0xE0: (0xA0, 0xBF), - 0xED: (0x80, 0x9F), - 0xF0: (0x90, 0xBF), - 0xF4: (0x80, 0x8F), -} - - -def _partial_suffix(payload: bytes) -> int: - # Bytes of an incomplete trailing multi-byte character; 0 when the slice - # ends on a character boundary. - for back in range(1, min(4, len(payload)) + 1): - byte = payload[-back] - if byte & 0xC0 == 0x80: - continue - # Only a sequence that can still complete is worth trimming; anything - # that never will (C0/C1 or F5+ leads, raw terminal noise, a first - # continuation outside the lead's constrained range — overlongs, - # surrogates, beyond U+10FFFF) is kept, replaced by decode(), and - # advanced past. - if 0xC2 <= byte <= 0xF4: - needed = 2 if byte < 0xE0 else 3 if byte < 0xF0 else 4 - if back < needed: - low, high = _FIRST_CONTINUATION.get(byte, (0x80, 0xBF)) - if back == 1 or low <= payload[-back + 1] <= high: - return back - return 0 - return 0 +def _complete_prefix(payload: bytes) -> bytes: + # Drop a trailing sequence the window truncated mid-character so the next + # read re-covers it; a genuinely invalid byte is kept (decode replaces it) + # so pagination never stalls. The decoder tells the two apart. + try: + payload.decode("utf-8") + except UnicodeDecodeError as error: + if error.reason == "unexpected end of data": + return payload[: error.start] + return payload def read_slice(path: Path, *, offset: int, limit: int) -> TextSlice: - # One bounded slice of a text file, snapped to UTF-8 character boundaries - # on both cut edges (the next read re-covers trimmed bytes). A negative - # offset addresses from the end — the tail |offset| bytes. Missing file: + # A bounded slice of a text file snapped to UTF-8 boundaries; the next read + # re-covers trimmed bytes. Negative offset reads the tail; missing file is # an empty eof slice. if not path.exists(): return TextSlice(offset=0, next_offset=0, eof=True, has_earlier=False, text="") - # Any single character fits, so boundary trims always leave progress — a - # smaller limit could sit on a four-byte character forever. - limit = max(limit, 4) + limit = max(limit, 4) # any character fits, so a trim always leaves progress size = path.stat().st_size if offset < 0: offset = max(size + offset, 0) @@ -186,11 +163,7 @@ def read_slice(path: Path, *, offset: int, limit: int) -> TextSlice: lead = _continuation_prefix(payload) offset += lead payload = payload[lead:] - # Trim a partial trailing sequence unconditionally: at EOF it means the - # writer is mid-character, and emitting � would advance past bytes whose - # completion the next read must re-cover. next_offset stays behind the - # trim, so eof reads false until the character lands whole. - payload = payload[: len(payload) - _partial_suffix(payload)] + payload = _complete_prefix(payload) next_offset = offset + len(payload) return TextSlice( offset=offset, From df9c6f20bbe99d42680b6faf8a88ca0d70bd9fab Mon Sep 17 00:00:00 2001 From: Paulo Date: Mon, 20 Jul 2026 12:09:21 +0200 Subject: [PATCH 13/21] Drop read_slice's UTF-8 boundary-snapping; cut a module comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit read_slice now reads a byte window and decodes errors=replace, exactly like stream_transcript beside it — a character split at a window seam shows one �, which is fine for a log. Removes _continuation_prefix, _complete_prefix, and the tiny-limit floor, and the tests that pinned the snapping. --- backend/druks/durable/reads.py | 32 +--------- backend/druks/mcp/gateway/services.py | 3 - backend/tests/test_agent_services.py | 88 ++++----------------------- 3 files changed, 15 insertions(+), 108 deletions(-) diff --git a/backend/druks/durable/reads.py b/backend/druks/durable/reads.py index 5fe3e89..d0a7f50 100644 --- a/backend/druks/durable/reads.py +++ b/backend/druks/durable/reads.py @@ -126,44 +126,18 @@ def _status( ) -def _continuation_prefix(payload: bytes) -> int: - # Bytes of a split character's tail at the head of a mid-file slice. - length = 0 - while length < min(3, len(payload)) and payload[length] & 0xC0 == 0x80: - length += 1 - return length - - -def _complete_prefix(payload: bytes) -> bytes: - # Drop a trailing sequence the window truncated mid-character so the next - # read re-covers it; a genuinely invalid byte is kept (decode replaces it) - # so pagination never stalls. The decoder tells the two apart. - try: - payload.decode("utf-8") - except UnicodeDecodeError as error: - if error.reason == "unexpected end of data": - return payload[: error.start] - return payload - - def read_slice(path: Path, *, offset: int, limit: int) -> TextSlice: - # A bounded slice of a text file snapped to UTF-8 boundaries; the next read - # re-covers trimmed bytes. Negative offset reads the tail; missing file is - # an empty eof slice. + # A bounded byte window of a text file. A multibyte character split at a + # window seam shows one � — the live tail below does the same. Negative + # offset reads the tail; missing file is an empty eof slice. if not path.exists(): return TextSlice(offset=0, next_offset=0, eof=True, has_earlier=False, text="") - limit = max(limit, 4) # any character fits, so a trim always leaves progress size = path.stat().st_size if offset < 0: offset = max(size + offset, 0) with path.open("rb") as handle: handle.seek(offset) payload = handle.read(limit) - if offset: - lead = _continuation_prefix(payload) - offset += lead - payload = payload[lead:] - payload = _complete_prefix(payload) next_offset = offset + len(payload) return TextSlice( offset=offset, diff --git a/backend/druks/mcp/gateway/services.py b/backend/druks/mcp/gateway/services.py index dbab50a..c84a9a8 100644 --- a/backend/druks/mcp/gateway/services.py +++ b/backend/druks/mcp/gateway/services.py @@ -1,6 +1,3 @@ -# The inbound agent gateway's platform operations over shared primitives: gate -# read/answer and cancel on runs, bounded agent-call detail, and the caller's -# quota and spend — served under the /mcp routes and, in T4, the MCP tools. from datetime import UTC, datetime, timedelta from typing import Any diff --git a/backend/tests/test_agent_services.py b/backend/tests/test_agent_services.py index 02957e9..a8ad5c8 100644 --- a/backend/tests/test_agent_services.py +++ b/backend/tests/test_agent_services.py @@ -68,94 +68,30 @@ def _park(db_session, item_id, *, ask=None): # ---- read_slice ----------------------------------------------------------- -def test_read_slice_tail_snaps_a_split_character(tmp_path: Path): +def test_read_slice_paginates_a_window(tmp_path: Path): path = tmp_path / "log.txt" - path.write_bytes(b"abcd" + "é".encode() + b"wxyz") - - piece = read_slice(path, offset=-5, limit=5) - - # Byte 5 is the é's continuation byte; the slice starts after it. - assert piece.offset == 6 - assert piece.text == "wxyz" - assert piece.has_earlier is True - assert piece.eof is True - - -def test_read_slice_head_trims_a_trailing_partial_character(tmp_path: Path): - path = tmp_path / "log.txt" - path.write_bytes(b"abcd" + "é".encode() + b"wxyz") + path.write_bytes(b"hello world") head = read_slice(path, offset=0, limit=5) - assert head.text == "abcd" - assert head.next_offset == 4 + assert head.text == "hello" + assert head.next_offset == 5 assert head.eof is False assert head.has_earlier is False rest = read_slice(path, offset=head.next_offset, limit=100) - assert rest.text == "éwxyz" - assert rest.eof is True - - -def test_read_slice_trims_a_partial_four_byte_character(tmp_path: Path): - path = tmp_path / "log.txt" - payload = "ab🎉cd".encode() - path.write_bytes(payload) - - piece = read_slice(path, offset=0, limit=4) # cuts the emoji after 2 of 4 bytes - - assert piece.text == "ab" - assert piece.next_offset == 2 - - -def test_read_slice_tiny_limit_still_progresses(tmp_path: Path): - path = tmp_path / "log.txt" - path.write_bytes("🎉x".encode()) - - # A limit smaller than one character can never advance; the floor of one - # whole character keeps pagination moving. - piece = read_slice(path, offset=0, limit=1) - assert piece.text == "🎉" - assert piece.next_offset == 4 - - rest = read_slice(path, offset=piece.next_offset, limit=1) - assert rest.text == "x" + assert rest.text == " world" assert rest.eof is True -def test_read_slice_invalid_bytes_still_progress(tmp_path: Path): - # Raw terminal output isn't guaranteed UTF-8: an invalid byte can never - # complete into a character, so it must be replaced and passed, not - # trimmed forever. +def test_read_slice_reads_the_tail(tmp_path: Path): path = tmp_path / "log.txt" - path.write_bytes(b"ok\xff") - - piece = read_slice(path, offset=0, limit=100) - assert piece.text == "ok�" - assert piece.next_offset == 3 - assert piece.eof is True - - # A surrogate prefix (ED A0) has a valid lead but can never complete; - # trimming it would stall the reader at offset 2 forever. - path.write_bytes(b"ok\xed\xa0") - stuck = read_slice(path, offset=0, limit=100) - assert stuck.text[:2] == "ok" - assert stuck.next_offset == 4 - assert stuck.eof is True - + path.write_bytes(b"hello world") -def test_read_slice_live_tail_re_covers_a_mid_write_character(tmp_path: Path): - path = tmp_path / "live.txt" - path.write_bytes(b"ab" + "🎉".encode()[:2]) # the writer is mid-emoji - - piece = read_slice(path, offset=0, limit=100) - assert piece.text == "ab" - assert piece.next_offset == 2 - assert piece.eof is False # the character isn't whole yet - - path.write_bytes(b"ab" + "🎉".encode()) - rest = read_slice(path, offset=piece.next_offset, limit=100) - assert rest.text == "🎉" - assert rest.eof is True + tail = read_slice(path, offset=-5, limit=5) + assert tail.offset == 6 + assert tail.text == "world" + assert tail.has_earlier is True + assert tail.eof is True def test_read_slice_missing_file_is_an_empty_eof(tmp_path: Path): From 6cf0b0ab6a4681c32a1e0d3c444c9c27b1489b79 Mon Sep 17 00:00:00 2001 From: Paulo Date: Mon, 20 Jul 2026 13:56:01 +0200 Subject: [PATCH 14/21] Drop the response byte-budget clipping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clip + _wire_cost truncated free-text fields (title, failure, last_error, plan_tier, the gate ask) to hold an exact serialized byte budget — codex-driven defense against pathological multibyte/ANSI content that doesn't occur. Gone, along with _bounded_ask: the gate ask is already bounded at its QuestionOutput contract. The real bounds stay — read_slice's byte windows on transcripts and artifacts, the note input cap, the agent-output contract caps. --- backend/druks/durable/schemas.py | 11 +++-------- backend/druks/mcp/gateway/services.py | 28 +++------------------------ backend/druks/schemas.py | 25 ------------------------ backend/tests/test_agent_services.py | 21 -------------------- 4 files changed, 6 insertions(+), 79 deletions(-) diff --git a/backend/druks/durable/schemas.py b/backend/druks/durable/schemas.py index 0c0836e..6de06ea 100644 --- a/backend/druks/durable/schemas.py +++ b/backend/druks/durable/schemas.py @@ -5,7 +5,7 @@ from pydantic import Field, SerializeAsAny from druks.harnesses.artifacts import normalize_token_usage -from druks.schemas import BaseResponse, clip +from druks.schemas import BaseResponse from .enums import AgentCallStatus, RunState from .models import AgentCall, Artifact, Run @@ -225,11 +225,6 @@ class TranscriptChunk(BaseResponse): text: str -# Free-text fields on the agent-surface summaries are clipped to this, so a -# stack trace can't blow a response budget. -_TEXT_CLIP = 160 - - class TextSlice(BaseResponse): # One bounded UTF-8-safe cut of an on-disk text file; offsets are byte # positions, has_earlier marks content before this slice. @@ -261,7 +256,7 @@ def from_call(cls, call: AgentCall) -> "AgentCallSummary": status=_derived_status(call), # type: ignore[arg-type] started_at=call.started_at, finished_at=call.finished_at, - last_error=clip(call.last_error, _TEXT_CLIP), + last_error=call.last_error, cost_usd=call.cost_usd, ) @@ -284,7 +279,7 @@ def from_run(cls, run: Run, calls: list[AgentCall]) -> "RunSummary": id=run.id, kind=run.kind, state=run.state, # type: ignore[arg-type] - failure=clip(run.failure, _TEXT_CLIP), + failure=run.failure, created_at=run.created_at, updated_at=run.updated_at, account_username=run.account.username, diff --git a/backend/druks/mcp/gateway/services.py b/backend/druks/mcp/gateway/services.py index c84a9a8..5babad0 100644 --- a/backend/druks/mcp/gateway/services.py +++ b/backend/druks/mcp/gateway/services.py @@ -31,7 +31,6 @@ ) from druks.notifications.exceptions import InvalidChoiceError from druks.notifications.services import validate_in_app_answer -from druks.schemas import clip from druks.usage.models import UsageScrape from druks.usage.reads import list_finished_calls from druks.usage.schemas import UsageHistoryPoint @@ -42,28 +41,7 @@ _STDERR_TAIL_BYTES = 4 * 1024 _ARTIFACT_CHUNK_BYTES = 4 * 1024 _NOTE_BYTES = 2 * 1024 -_PROMPT_CLIP = 2 * 1024 -_OPTION_LABEL_CLIP = 256 -# Each usage trend stays this short so the whole response holds its byte budget. -_HISTORY_POINTS = 8 - - -def _bounded_ask(ask: dict[str, Any]) -> dict[str, Any]: - # The ask is agent-authored free text, so the view bounds each field like - # every other budgeted response. Question COUNT stays whole — dropping a - # question would make the gate unanswerable; real asks hold a handful. - questions = [ - { - **question, - "prompt": clip(question.get("prompt"), _PROMPT_CLIP), - "options": [ - {**option, "label": clip(option.get("label"), _OPTION_LABEL_CLIP)} - for option in question.get("options", []) - ], - } - for question in ask.get("questions", []) - ] - return {**ask, "questions": questions} +_HISTORY_POINTS = 8 # trend points per harness on the usage response def get_gate(run_id: str) -> GateDetail: @@ -77,7 +55,7 @@ def get_gate(run_id: str) -> GateDetail: # External gates are answered on their source (PR review, ticket # comment); an ask-less park offers no reply contract either. raise GateNotAnswerable(run_id) - ask = _bounded_ask(run.get_ask()) + ask = run.get_ask() return GateDetail( run_id=run.id, gate=run.input_gate, # type: ignore[arg-type] @@ -237,7 +215,7 @@ def _harness_usage(name: str, account_id: str, *, now: datetime) -> AgentHarness return AgentHarnessUsage( name=name, is_connected=is_connected, - plan_tier=clip(row.plan_tier, 64), + plan_tier=row.plan_tier, five_hour_percent_left=row.five_hour_percent_left, five_hour_resets_at=row.five_hour_resets_at, week_percent_left=row.week_percent_left, diff --git a/backend/druks/schemas.py b/backend/druks/schemas.py index c8058fe..f34d874 100644 --- a/backend/druks/schemas.py +++ b/backend/druks/schemas.py @@ -1,5 +1,3 @@ -import json - from pydantic import BaseModel, ConfigDict from pydantic.alias_generators import to_camel @@ -11,27 +9,4 @@ class BaseResponse(BaseModel): model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) -def _wire_cost(char: str) -> int: - # What one character spends of a wire budget: its JSON-escaped UTF-8 bytes - # (a control byte like ESC serializes as  — six bytes, not one). - return len(json.dumps(char, ensure_ascii=False).encode()) - 2 - - -def clip(text: str | None, limit: int) -> str | None: - # Budgeted read-sides bound their free-text fields by what the field will - # occupy in the serialized response, so the budgets hold for multibyte and - # escape-heavy text alike. The ellipsis (3 bytes) marks the cut. - if not text: - return text - total = 0 - cut = None - for index, char in enumerate(text): - total += _wire_cost(char) - if cut is None and total > limit - 3: - cut = index - if total > limit: - return text[:cut] + "…" - return text - - __all__ = ["BaseResponse"] diff --git a/backend/tests/test_agent_services.py b/backend/tests/test_agent_services.py index a8ad5c8..447c0ea 100644 --- a/backend/tests/test_agent_services.py +++ b/backend/tests/test_agent_services.py @@ -1,4 +1,3 @@ -import json from datetime import UTC, datetime, timedelta from pathlib import Path @@ -121,26 +120,6 @@ def test_get_gate_returns_ask_schema_and_parked_at(db_session): assert schema["required"] == ["control"] -def test_get_gate_bounds_an_agent_authored_ask(db_session): - item = make_test_work_item(repo="o/r", title="t") - question = { - "id": "q1", - "prompt": "🦖" * 5000, - "options": [{"id": "a", "label": "💥" * 500}], - } - run = _park(db_session, item.id, ask=_in_app_ask([question])) - - view = services.get_gate(run.id) - - prompt = view.ask["questions"][0]["prompt"] - label = view.ask["questions"][0]["options"][0]["label"] - assert len(json.dumps(prompt, ensure_ascii=False).encode()) - 2 <= 2048 - assert len(json.dumps(label, ensure_ascii=False).encode()) - 2 <= 256 - # The reply schema derives from the bounded ask, so its description is the - # clipped prompt — one bounded view, both structures. - assert view.reply_schema["properties"]["answers"]["properties"]["q1"]["description"] == prompt - - def test_get_gate_serves_a_bounded_artifact_chunk(db_session): item = make_test_work_item(repo="o/r", title="t") run = _park(db_session, item.id) From 38b1cd11b30c67c5476665071d7fab232105d613 Mon Sep 17 00:00:00 2001 From: Paulo Date: Mon, 20 Jul 2026 14:07:56 +0200 Subject: [PATCH 15/21] Drop dead list_recent_runs and give agents plain log text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list_recent_runs (and its calls_limit) plus RunSummary were orphaned when the build get_work_item parked — deleted. And get_agent_call now returns the transcript/stderr tails and artifact as plain strings, not TextSlice offset cursors: an agent reading a past call wants the log text, not a paginated window. read_slice + TranscriptChunk stay for the dashboard viewer, which actually paginates and live-tails. --- backend/druks/durable/reads.py | 11 ----------- backend/druks/durable/schemas.py | 26 -------------------------- backend/druks/mcp/gateway/schemas.py | 16 +++++++--------- backend/druks/mcp/gateway/services.py | 16 ++++++++-------- backend/tests/test_agent_services.py | 22 ++++++++-------------- 5 files changed, 23 insertions(+), 68 deletions(-) diff --git a/backend/druks/durable/reads.py b/backend/druks/durable/reads.py index d0a7f50..aa45309 100644 --- a/backend/druks/durable/reads.py +++ b/backend/druks/durable/reads.py @@ -16,7 +16,6 @@ AgentCallFiles, AgentCallResponse, RunResponse, - RunSummary, SubjectActivity, SubjectResponse, SubjectStatus, @@ -50,16 +49,6 @@ def get_subject_status(subject_type: str, subject_id: str) -> SubjectStatus: return _status(runs, active_run, _running_calls(active_run)) -def list_recent_runs( - subject_type: str, subject_id: str, *, limit: int, calls_limit: int -) -> list[RunSummary]: - # The subject's newest runs, each with its latest agent calls — the bounded - # agent-surface cut of list_subject_timeline. - runs = Run.list_for_subject(subject_type, subject_id)[:limit] - calls_by_run = AgentCall.by_run([run.id for run in runs]) - return [RunSummary.from_run(run, calls_by_run[run.id][-calls_limit:]) for run in runs] - - def get_subject_response( subject_type: str, subject_id: str, diff --git a/backend/druks/durable/schemas.py b/backend/druks/durable/schemas.py index 6de06ea..c606cff 100644 --- a/backend/druks/durable/schemas.py +++ b/backend/druks/durable/schemas.py @@ -259,29 +259,3 @@ def from_call(cls, call: AgentCall) -> "AgentCallSummary": last_error=call.last_error, cost_usd=call.cost_usd, ) - - -class RunSummary(BaseResponse): - # One run for the agent surface, with its latest agent calls — the bounded - # cut of RunResponse (no ask payload; get_gate serves the live one). - id: str - kind: str - state: Literal["scheduled", "running", "pending_input", "finished", "failed", "cancelled"] - failure: str | None = None - created_at: datetime - updated_at: datetime - account_username: str - agent_calls: list[AgentCallSummary] = Field(default_factory=list) - - @classmethod - def from_run(cls, run: Run, calls: list[AgentCall]) -> "RunSummary": - return cls( - id=run.id, - kind=run.kind, - state=run.state, # type: ignore[arg-type] - failure=run.failure, - created_at=run.created_at, - updated_at=run.updated_at, - account_username=run.account.username, - agent_calls=[AgentCallSummary.from_call(call) for call in calls], - ) diff --git a/backend/druks/mcp/gateway/schemas.py b/backend/druks/mcp/gateway/schemas.py index 2c8bc0a..691095c 100644 --- a/backend/druks/mcp/gateway/schemas.py +++ b/backend/druks/mcp/gateway/schemas.py @@ -4,18 +4,16 @@ from pydantic import AwareDatetime, BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel -from druks.durable.schemas import AgentCallSummary, TextSlice +from druks.durable.schemas import AgentCallSummary from druks.schemas import BaseResponse from druks.usage.schemas import UsageHistoryPoint -class ArtifactChunk(BaseResponse): - # A call's renderable output, head-bounded; page the rest through the - # call's transcript files route. +class ArtifactContent(BaseResponse): call_id: str kind: str title: str - chunk: TextSlice + content: str class GateDetail(BaseResponse): @@ -26,7 +24,7 @@ class GateDetail(BaseResponse): gate: str parked_at: datetime ask: dict[str, Any] - artifact: ArtifactChunk | None = None + artifact: ArtifactContent | None = None reply_schema: dict[str, Any] @@ -51,9 +49,9 @@ class AnswerGateRequest(BaseModel): class AgentCallDetail(BaseResponse): run_id: str call: AgentCallSummary - transcript: TextSlice - stderr: TextSlice - artifact: ArtifactChunk | None = None + transcript: str + stderr: str + artifact: ArtifactContent | None = None class CancelRunResult(BaseResponse): diff --git a/backend/druks/mcp/gateway/services.py b/backend/druks/mcp/gateway/services.py index 5babad0..a0cd4e9 100644 --- a/backend/druks/mcp/gateway/services.py +++ b/backend/druks/mcp/gateway/services.py @@ -24,7 +24,7 @@ AgentCallDetail, AgentHarnessUsage, AgentUsage, - ArtifactChunk, + ArtifactContent, CancelRunResult, GateAnswerResult, GateDetail, @@ -61,7 +61,7 @@ def get_gate(run_id: str) -> GateDetail: gate=run.input_gate, # type: ignore[arg-type] parked_at=run.input_requested_at, # type: ignore[arg-type] ask=ask, - artifact=_artifact_chunk(Artifact.get_latest_for_run(run.id)), + artifact=_artifact_content(Artifact.get_latest_for_run(run.id)), reply_schema=_reply_schema(ask), ) @@ -110,9 +110,9 @@ def get_agent_call(call_id: str) -> AgentCallDetail: call=AgentCallSummary.from_call(call), transcript=read_slice( layout.transcript, offset=-_TRANSCRIPT_TAIL_BYTES, limit=_TRANSCRIPT_TAIL_BYTES - ), - stderr=read_slice(layout.stderr, offset=-_STDERR_TAIL_BYTES, limit=_STDERR_TAIL_BYTES), - artifact=_artifact_chunk(Artifact.get_for_call(call.id)), + ).text, + stderr=read_slice(layout.stderr, offset=-_STDERR_TAIL_BYTES, limit=_STDERR_TAIL_BYTES).text, + artifact=_artifact_content(Artifact.get_for_call(call.id)), ) @@ -128,18 +128,18 @@ async def cancel_run(run_id: str, *, reason: str) -> CancelRunResult: return CancelRunResult(run_id=run.id, result="cancelled") -def _artifact_chunk(artifact: Artifact | None) -> ArtifactChunk | None: +def _artifact_content(artifact: Artifact | None) -> ArtifactContent | None: if not artifact: return call = AgentCall.get(artifact.agent_call_id) path = call.get_file_path(artifact.path) if call else None if not path: return - return ArtifactChunk( + return ArtifactContent( call_id=artifact.agent_call_id, kind=artifact.kind, title=artifact.title, - chunk=read_slice(path, offset=0, limit=_ARTIFACT_CHUNK_BYTES), + content=read_slice(path, offset=0, limit=_ARTIFACT_CHUNK_BYTES).text, ) diff --git a/backend/tests/test_agent_services.py b/backend/tests/test_agent_services.py index 447c0ea..71dc26f 100644 --- a/backend/tests/test_agent_services.py +++ b/backend/tests/test_agent_services.py @@ -120,7 +120,7 @@ def test_get_gate_returns_ask_schema_and_parked_at(db_session): assert schema["required"] == ["control"] -def test_get_gate_serves_a_bounded_artifact_chunk(db_session): +def test_get_gate_serves_the_artifact(db_session): item = make_test_work_item(repo="o/r", title="t") run = _park(db_session, item.id) call = seed_call(db_session, run, "generate_plan") @@ -133,8 +133,7 @@ def test_get_gate_serves_a_bounded_artifact_chunk(db_session): assert view.artifact is not None assert view.artifact.call_id == call.id assert view.artifact.title == "Plan" - assert len(view.artifact.chunk.text.encode()) <= 4096 - assert view.artifact.chunk.eof is False + assert len(view.artifact.content.encode()) <= 4096 def test_get_gate_refuses_when_not_parked_or_external(db_session): @@ -262,15 +261,11 @@ def test_get_agent_call_serves_bounded_tails(db_session): assert detail.run_id == call.run_id assert detail.call.id == call.id - assert len(detail.call.last_error or "") <= 160 - assert len(detail.transcript.text.encode()) <= 8192 - assert detail.transcript.offset == 20480 - 8192 - assert detail.transcript.eof is True - assert detail.transcript.has_earlier is True - assert len(detail.stderr.text.encode()) <= 4096 - assert detail.stderr.has_earlier is True + assert detail.call.last_error == "boom " * 100 + assert detail.transcript == "s" * 8192 + assert detail.stderr == "e" * 4096 assert detail.artifact is not None - assert len(detail.artifact.chunk.text.encode()) <= 4096 + assert detail.artifact.content == "a" * 4096 with pytest.raises(AgentCallNotFound): services.get_agent_call("no-such-call") @@ -283,9 +278,8 @@ def test_get_agent_call_without_files_reads_empty(db_session): detail = services.get_agent_call(call.id) - assert detail.transcript.text == "" - assert detail.transcript.eof is True - assert detail.stderr.has_earlier is False + assert detail.transcript == "" + assert detail.stderr == "" assert detail.artifact is None From ae55112c82dc55e4fedb4e53647ee148543a792a Mon Sep 17 00:00:00 2001 From: Paulo Date: Mon, 20 Jul 2026 14:16:59 +0200 Subject: [PATCH 16/21] Simplification pass over the agent gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reply_schema / _reply_schema: a JSON Schema generated from the ask, which already returns controls + questions — pure restatement, removed. - _NOTE_BYTES note cap: the same defend-against-input-that-never-arrives as the byte-budget clipping — removed. - AgentCallSummary: a near-duplicate of AgentCallResponse whose whole rationale ('clipped free text') died with the clip removal — merged into AgentCallResponse. - narration comments across get_gate/answer_gate and the schemas cut to the one constraint the code can't show. --- backend/druks/durable/schemas.py | 26 ------------- backend/druks/mcp/gateway/schemas.py | 18 +++------ backend/druks/mcp/gateway/services.py | 56 +++------------------------ backend/tests/test_agent_services.py | 15 +------ 4 files changed, 12 insertions(+), 103 deletions(-) diff --git a/backend/druks/durable/schemas.py b/backend/druks/durable/schemas.py index c606cff..6b7034f 100644 --- a/backend/druks/durable/schemas.py +++ b/backend/druks/durable/schemas.py @@ -233,29 +233,3 @@ class TextSlice(BaseResponse): eof: bool has_earlier: bool text: str - - -class AgentCallSummary(BaseResponse): - # The bounded agent-surface cut of AgentCallResponse: the same facts with - # clipped free text and no token breakdown. - id: str - agent: str | None = None - account_username: str - status: Literal["running", "succeeded", "failed", "abandoned"] - started_at: datetime - finished_at: datetime | None = None - last_error: str | None = None - cost_usd: float | None = None - - @classmethod - def from_call(cls, call: AgentCall) -> "AgentCallSummary": - return cls( - id=call.id, - agent=call.agent, - account_username=call.account.username, - status=_derived_status(call), # type: ignore[arg-type] - started_at=call.started_at, - finished_at=call.finished_at, - last_error=call.last_error, - cost_usd=call.cost_usd, - ) diff --git a/backend/druks/mcp/gateway/schemas.py b/backend/druks/mcp/gateway/schemas.py index 691095c..fbba555 100644 --- a/backend/druks/mcp/gateway/schemas.py +++ b/backend/druks/mcp/gateway/schemas.py @@ -4,7 +4,7 @@ from pydantic import AwareDatetime, BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel -from druks.durable.schemas import AgentCallSummary +from druks.durable.schemas import AgentCallResponse from druks.schemas import BaseResponse from druks.usage.schemas import UsageHistoryPoint @@ -17,15 +17,12 @@ class ArtifactContent(BaseResponse): class GateDetail(BaseResponse): - # Everything needed to answer a parked run in one read: the ask, the - # artifact under review, the reply's JSON Schema, and parked_at — the park - # identity answer_gate must echo back. + # parked_at is the park identity answer_gate must echo back. run_id: str gate: str parked_at: datetime ask: dict[str, Any] artifact: ArtifactContent | None = None - reply_schema: dict[str, Any] class GateAnswerResult(BaseResponse): @@ -35,10 +32,7 @@ class GateAnswerResult(BaseResponse): class AnswerGateRequest(BaseModel): - # parkedAt echoes get_gate's response key unchanged — the park identity the - # answer must land on; one camelCase wire both directions. The rest mirrors - # ResumeRequest: a control the ask offered, an answer per open question, an - # optional free-text note. + # parked_at echoes get_gate's value unchanged. model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel) parked_at: AwareDatetime control: str @@ -48,7 +42,7 @@ class AnswerGateRequest(BaseModel): class AgentCallDetail(BaseResponse): run_id: str - call: AgentCallSummary + call: AgentCallResponse transcript: str stderr: str artifact: ArtifactContent | None = None @@ -60,8 +54,7 @@ class CancelRunResult(BaseResponse): class AgentHarnessUsage(BaseResponse): - # One harness's quota for the agent surface: the latest snapshot's facts - # plus a short percent-left trend per window, oldest first. + # *_history: percent-left trend samples, oldest first. name: str is_connected: bool = False plan_tier: str | None = None @@ -76,7 +69,6 @@ class AgentHarnessUsage(BaseResponse): class AgentUsage(BaseResponse): - # The caller's spend for the operator-local day plus per-harness quota. day: str timezone: str spend_today_usd: float diff --git a/backend/druks/mcp/gateway/services.py b/backend/druks/mcp/gateway/services.py index a0cd4e9..306cdb9 100644 --- a/backend/druks/mcp/gateway/services.py +++ b/backend/druks/mcp/gateway/services.py @@ -1,5 +1,4 @@ from datetime import UTC, datetime, timedelta -from typing import Any from druks.accounts.models import Account from druks.core.utils.time import operator_local_day @@ -7,7 +6,7 @@ from druks.durable.enums import RunState from druks.durable.models import AgentCall, Artifact, Run from druks.durable.reads import read_slice -from druks.durable.schemas import AgentCallSummary +from druks.durable.schemas import AgentCallResponse from druks.harnesses.artifacts import normalize_token_usage from druks.harnesses.models import HarnessConnection from druks.harnesses.registry import get_harnesses @@ -40,8 +39,7 @@ _TRANSCRIPT_TAIL_BYTES = 8 * 1024 _STDERR_TAIL_BYTES = 4 * 1024 _ARTIFACT_CHUNK_BYTES = 4 * 1024 -_NOTE_BYTES = 2 * 1024 -_HISTORY_POINTS = 8 # trend points per harness on the usage response +_HISTORY_POINTS = 8 def get_gate(run_id: str) -> GateDetail: @@ -52,17 +50,13 @@ def get_gate(run_id: str) -> GateDetail: raise GateNotOpen(run_id) ask = run.input_request if not ask or ask.get("presentation") != "in_app": - # External gates are answered on their source (PR review, ticket - # comment); an ask-less park offers no reply contract either. raise GateNotAnswerable(run_id) - ask = run.get_ask() return GateDetail( run_id=run.id, gate=run.input_gate, # type: ignore[arg-type] parked_at=run.input_requested_at, # type: ignore[arg-type] - ask=ask, + ask=run.get_ask(), artifact=_artifact_content(Artifact.get_latest_for_run(run.id)), - reply_schema=_reply_schema(ask), ) @@ -72,12 +66,8 @@ async def answer_gate( run = Run.get(run_id) if not run: raise RunNotFound(run_id) - # Same freshness discipline as notifications/services.py: the answer must - # land on the run's live park, so the comparison reads fresh. - db_session().expire(run) + db_session().expire(run) # the receipt/park comparison must read fresh if run.answer_parked_at == parked_at: - # The receipt names the parked_at an answer already resumed — a late - # or duplicate answer collapses here instead of reading "not open". return GateAnswerResult(run_id=run.id, parked_at=parked_at, result="already_answered") if run.state != RunState.PENDING_INPUT.value: raise GateNotOpen(run_id) @@ -86,16 +76,10 @@ async def answer_gate( ask = run.input_request if not ask or ask.get("presentation") != "in_app": raise GateNotAnswerable(run_id) - if len(note.encode()) > _NOTE_BYTES: - raise InvalidGateAnswer(f"note exceeds {_NOTE_BYTES} bytes") try: payload = validate_in_app_answer(run.get_ask(), control, answers, note) except InvalidChoiceError as error: - # The one validation authority stays in notifications; this surface - # speaks the agent taxonomy. raise InvalidGateAnswer(str(error)) from error - # Run.resume keys the DBOS send by (gate, input_requested_at), so a - # concurrent duplicate answer to the same parked_at collapses engine-side. await run.resume(**payload) return GateAnswerResult(run_id=run.id, parked_at=parked_at, result="answered") @@ -107,7 +91,7 @@ def get_agent_call(call_id: str) -> AgentCallDetail: layout = call.artifact_layout return AgentCallDetail( run_id=call.run_id, - call=AgentCallSummary.from_call(call), + call=AgentCallResponse.from_call(call), transcript=read_slice( layout.transcript, offset=-_TRANSCRIPT_TAIL_BYTES, limit=_TRANSCRIPT_TAIL_BYTES ).text, @@ -143,36 +127,6 @@ def _artifact_content(artifact: Artifact | None) -> ArtifactContent | None: ) -def _reply_schema(ask: dict[str, Any]) -> dict[str, Any]: - # What answer_gate accepts for this ask, as JSON Schema — the agent-facing - # twin of validate_in_app_answer: a control from the offered vocabulary, an - # answer per open question (an offered option id or the caller's own - # words), and a free-text note. - answers = { - # pattern \S: the service strips whitespace, so a blank answer is - # rejected — the schema says so up front. - question["id"]: {"type": "string", "pattern": r"\S", "description": question["prompt"]} - for question in ask.get("questions", []) - } - control: dict[str, Any] = {"type": "string", "enum": list(ask.get("controls", []))} - if "request_changes" in control["enum"]: - control["description"] = "request_changes needs an answer or a note to guide the re-plan." - return { - "type": "object", - "properties": { - "control": control, - "answers": {"type": "object", "properties": answers, "additionalProperties": False}, - "note": { - "type": "string", - "maxLength": _NOTE_BYTES, - "description": f"At most {_NOTE_BYTES} UTF-8 bytes.", - }, - }, - "required": ["control"], - "additionalProperties": False, - } - - def get_usage(account: Account) -> AgentUsage: now = datetime.now(UTC) timezone, local_start = operator_local_day(UserSettings.get().timezone, now) diff --git a/backend/tests/test_agent_services.py b/backend/tests/test_agent_services.py index 71dc26f..2c91c39 100644 --- a/backend/tests/test_agent_services.py +++ b/backend/tests/test_agent_services.py @@ -103,7 +103,7 @@ def test_read_slice_missing_file_is_an_empty_eof(tmp_path: Path): # ---- gates ---------------------------------------------------------------- -def test_get_gate_returns_ask_schema_and_parked_at(db_session): +def test_get_gate_returns_the_ask_and_parked_at(db_session): item = make_test_work_item(repo="o/r", title="t") question = {"id": "q1", "prompt": "Which db?", "options": [{"id": "pg", "label": "Postgres"}]} run = _park(db_session, item.id, ask=_in_app_ask([question])) @@ -114,10 +114,7 @@ def test_get_gate_returns_ask_schema_and_parked_at(db_session): assert view.gate == "review" assert view.parked_at == run.input_requested_at assert view.ask["controls"] == ["approve", "request_changes", "cancel"] - schema = view.reply_schema - assert schema["properties"]["control"]["enum"] == ["approve", "request_changes", "cancel"] - assert schema["properties"]["answers"]["properties"]["q1"]["description"] == "Which db?" - assert schema["required"] == ["control"] + assert view.ask["questions"][0]["prompt"] == "Which db?" def test_get_gate_serves_the_artifact(db_session): @@ -215,14 +212,6 @@ async def test_answer_gate_error_taxonomy(db_session, resume_spy): await services.answer_gate( run.id, parked_at=run.input_requested_at, control="merge", answers={}, note="" ) - with pytest.raises(InvalidGateAnswer): - await services.answer_gate( - run.id, - parked_at=run.input_requested_at, - control="approve", - answers={}, - note="n" * 2049, - ) external_item = make_test_work_item(repo="o/r3", title="t") external = _park( From 0b6278f46a00bc859ac36bbf2661b57e02335fab Mon Sep 17 00:00:00 2001 From: Paulo Date: Mon, 20 Jul 2026 14:30:49 +0200 Subject: [PATCH 17/21] Move a call's derived status onto AgentCall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _derived_status computed a call's live status (running / abandoned / its recorded outcome) but sat as a module helper in schemas.py — it's behavior about the call, so it's a property on the model. --- backend/druks/durable/models.py | 9 +++++++++ backend/druks/durable/schemas.py | 15 ++------------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/backend/druks/durable/models.py b/backend/druks/durable/models.py index 893dd2d..227ed1d 100644 --- a/backend/druks/durable/models.py +++ b/backend/druks/durable/models.py @@ -252,6 +252,15 @@ class AgentCall(Base, Uuid7Pk): # and treat 404 as the host being gone. sandbox_host_id: Mapped[str] + @property + def derived_status(self) -> str: + # Unfinished: "running" while the run is live, "abandoned" once it's terminal. + if self.finished_at: + return AgentCallStatus(self.status).value + if self.run.is_active: + return "running" + return "abandoned" + @property def artifact_dir(self) -> str: return str(load_settings().artifacts_dir / f"run-{self.run_id}") diff --git a/backend/druks/durable/schemas.py b/backend/druks/durable/schemas.py index 6b7034f..53a2f8e 100644 --- a/backend/druks/durable/schemas.py +++ b/backend/druks/durable/schemas.py @@ -7,7 +7,7 @@ from druks.harnesses.artifacts import normalize_token_usage from druks.schemas import BaseResponse -from .enums import AgentCallStatus, RunState +from .enums import RunState from .models import AgentCall, Artifact, Run @@ -30,17 +30,6 @@ def get_display_label(kind: str) -> str: return kind.rsplit(".", 1)[-1].replace("_", " ").capitalize() -def _derived_status(call: AgentCall) -> str: - # Liveness is derived, not stored: an unfinished call reads "running" while its - # run is active and "abandoned" once the run is terminal (the run ended without - # the call closing). A finished call keeps its recorded outcome. - if call.finished_at: - return AgentCallStatus(call.status).value - if call.run.is_active: - return "running" - return "abandoned" - - class AgentCallResponse(BaseResponse): id: str # Which agent made this call ("scope", "implement") — the timeline's row label. @@ -64,7 +53,7 @@ def from_call(cls, call: AgentCall) -> "AgentCallResponse": agent=call.agent, label=get_display_label(call.agent) if call.agent else "Agent", account_username=call.account.username, - status=_derived_status(call), # type: ignore[arg-type] + status=call.derived_status, # type: ignore[arg-type] started_at=call.started_at, finished_at=call.finished_at, last_error=call.last_error, From e69262593b1521581f206f0ac97190430686cc62 Mon Sep 17 00:00:00 2001 From: Paulo Date: Mon, 20 Jul 2026 14:38:21 +0200 Subject: [PATCH 18/21] Import gateway exceptions/schemas as modules, not name lists As these collections grow, import the module and reach through it (exceptions.RunNotFound, schemas.GateDetail) instead of maintaining a per-class import block at every call site. --- backend/druks/mcp/gateway/routes.py | 30 ++++------- backend/druks/mcp/gateway/services.py | 77 +++++++++++---------------- backend/tests/test_agent_services.py | 32 +++++------ 3 files changed, 54 insertions(+), 85 deletions(-) diff --git a/backend/druks/mcp/gateway/routes.py b/backend/druks/mcp/gateway/routes.py index 2662441..f791a37 100644 --- a/backend/druks/mcp/gateway/routes.py +++ b/backend/druks/mcp/gateway/routes.py @@ -4,15 +4,7 @@ from druks.accounts.dependencies import current_account from druks.accounts.models import Account -from druks.mcp.gateway import services -from druks.mcp.gateway.schemas import ( - AgentCallDetail, - AgentUsage, - AnswerGateRequest, - CancelRunResult, - GateAnswerResult, - GateDetail, -) +from druks.mcp.gateway import schemas, services router = APIRouter(prefix="/mcp", tags=["mcp"]) @@ -20,20 +12,20 @@ @router.get( "/gates/{run_id}", operation_id="get_gate", - response_model=GateDetail, + response_model=schemas.GateDetail, response_model_by_alias=True, ) -async def get_gate(run_id: str) -> GateDetail: +async def get_gate(run_id: str) -> schemas.GateDetail: return services.get_gate(run_id) @router.post( "/gates/{run_id}/answer", operation_id="answer_gate", - response_model=GateAnswerResult, + response_model=schemas.GateAnswerResult, response_model_by_alias=True, ) -async def answer_gate(run_id: str, body: AnswerGateRequest) -> GateAnswerResult: +async def answer_gate(run_id: str, body: schemas.AnswerGateRequest) -> schemas.GateAnswerResult: return await services.answer_gate( run_id, parked_at=body.parked_at, @@ -46,30 +38,30 @@ async def answer_gate(run_id: str, body: AnswerGateRequest) -> GateAnswerResult: @router.get( "/agent-calls/{call_id}", operation_id="get_agent_call", - response_model=AgentCallDetail, + response_model=schemas.AgentCallDetail, response_model_by_alias=True, ) -async def get_agent_call(call_id: str) -> AgentCallDetail: +async def get_agent_call(call_id: str) -> schemas.AgentCallDetail: return services.get_agent_call(call_id) @router.post( "/runs/{run_id}/cancel", operation_id="cancel_run", - response_model=CancelRunResult, + response_model=schemas.CancelRunResult, response_model_by_alias=True, ) async def cancel_run( run_id: str, reason: Annotated[str, Body(embed=True, min_length=1, max_length=500)] -) -> CancelRunResult: +) -> schemas.CancelRunResult: return await services.cancel_run(run_id, reason=reason) @router.get( "/usage", operation_id="get_usage", - response_model=AgentUsage, + response_model=schemas.AgentUsage, response_model_by_alias=True, ) -async def get_usage(account: Account = Depends(current_account)) -> AgentUsage: +async def get_usage(account: Account = Depends(current_account)) -> schemas.AgentUsage: return services.get_usage(account) diff --git a/backend/druks/mcp/gateway/services.py b/backend/druks/mcp/gateway/services.py index 306cdb9..869984f 100644 --- a/backend/druks/mcp/gateway/services.py +++ b/backend/druks/mcp/gateway/services.py @@ -10,24 +10,7 @@ from druks.harnesses.artifacts import normalize_token_usage from druks.harnesses.models import HarnessConnection from druks.harnesses.registry import get_harnesses -from druks.mcp.gateway.exceptions import ( - AgentCallNotFound, - GateNotAnswerable, - GateNotOpen, - GateRoundStale, - InvalidGateAnswer, - RunNotActive, - RunNotFound, -) -from druks.mcp.gateway.schemas import ( - AgentCallDetail, - AgentHarnessUsage, - AgentUsage, - ArtifactContent, - CancelRunResult, - GateAnswerResult, - GateDetail, -) +from druks.mcp.gateway import exceptions, schemas from druks.notifications.exceptions import InvalidChoiceError from druks.notifications.services import validate_in_app_answer from druks.usage.models import UsageScrape @@ -42,16 +25,16 @@ _HISTORY_POINTS = 8 -def get_gate(run_id: str) -> GateDetail: +def get_gate(run_id: str) -> schemas.GateDetail: run = Run.get(run_id) if not run: - raise RunNotFound(run_id) + raise exceptions.RunNotFound(run_id) if run.state != RunState.PENDING_INPUT.value: - raise GateNotOpen(run_id) + raise exceptions.GateNotOpen(run_id) ask = run.input_request if not ask or ask.get("presentation") != "in_app": - raise GateNotAnswerable(run_id) - return GateDetail( + raise exceptions.GateNotAnswerable(run_id) + return schemas.GateDetail( run_id=run.id, gate=run.input_gate, # type: ignore[arg-type] parked_at=run.input_requested_at, # type: ignore[arg-type] @@ -62,34 +45,36 @@ def get_gate(run_id: str) -> GateDetail: async def answer_gate( run_id: str, *, parked_at: datetime, control: str, answers: dict[str, str], note: str -) -> GateAnswerResult: +) -> schemas.GateAnswerResult: run = Run.get(run_id) if not run: - raise RunNotFound(run_id) + raise exceptions.RunNotFound(run_id) db_session().expire(run) # the receipt/park comparison must read fresh if run.answer_parked_at == parked_at: - return GateAnswerResult(run_id=run.id, parked_at=parked_at, result="already_answered") + return schemas.GateAnswerResult( + run_id=run.id, parked_at=parked_at, result="already_answered" + ) if run.state != RunState.PENDING_INPUT.value: - raise GateNotOpen(run_id) + raise exceptions.GateNotOpen(run_id) if run.input_requested_at != parked_at: - raise GateRoundStale(run_id) + raise exceptions.GateRoundStale(run_id) ask = run.input_request if not ask or ask.get("presentation") != "in_app": - raise GateNotAnswerable(run_id) + raise exceptions.GateNotAnswerable(run_id) try: payload = validate_in_app_answer(run.get_ask(), control, answers, note) except InvalidChoiceError as error: - raise InvalidGateAnswer(str(error)) from error + raise exceptions.InvalidGateAnswer(str(error)) from error await run.resume(**payload) - return GateAnswerResult(run_id=run.id, parked_at=parked_at, result="answered") + return schemas.GateAnswerResult(run_id=run.id, parked_at=parked_at, result="answered") -def get_agent_call(call_id: str) -> AgentCallDetail: +def get_agent_call(call_id: str) -> schemas.AgentCallDetail: call = AgentCall.get(call_id) if not call: - raise AgentCallNotFound(call_id) + raise exceptions.AgentCallNotFound(call_id) layout = call.artifact_layout - return AgentCallDetail( + return schemas.AgentCallDetail( run_id=call.run_id, call=AgentCallResponse.from_call(call), transcript=read_slice( @@ -100,26 +85,26 @@ def get_agent_call(call_id: str) -> AgentCallDetail: ) -async def cancel_run(run_id: str, *, reason: str) -> CancelRunResult: +async def cancel_run(run_id: str, *, reason: str) -> schemas.CancelRunResult: run = Run.get(run_id) if not run: - raise RunNotFound(run_id) + raise exceptions.RunNotFound(run_id) if run.state == RunState.CANCELLED.value: - return CancelRunResult(run_id=run.id, result="already_cancelled") + return schemas.CancelRunResult(run_id=run.id, result="already_cancelled") if not run.is_active: - raise RunNotActive(run_id) + raise exceptions.RunNotActive(run_id) await run.cancel(failure=reason) - return CancelRunResult(run_id=run.id, result="cancelled") + return schemas.CancelRunResult(run_id=run.id, result="cancelled") -def _artifact_content(artifact: Artifact | None) -> ArtifactContent | None: +def _artifact_content(artifact: Artifact | None) -> schemas.ArtifactContent | None: if not artifact: return call = AgentCall.get(artifact.agent_call_id) path = call.get_file_path(artifact.path) if call else None if not path: return - return ArtifactContent( + return schemas.ArtifactContent( call_id=artifact.agent_call_id, kind=artifact.kind, title=artifact.title, @@ -127,7 +112,7 @@ def _artifact_content(artifact: Artifact | None) -> ArtifactContent | None: ) -def get_usage(account: Account) -> AgentUsage: +def get_usage(account: Account) -> schemas.AgentUsage: now = datetime.now(UTC) timezone, local_start = operator_local_day(UserSettings.get().timezone, now) rows = list_finished_calls(account.id, since=local_start, until=local_start + timedelta(days=1)) @@ -139,7 +124,7 @@ def get_usage(account: Account) -> AgentUsage: usage = normalize_token_usage(cost_metadata) if usage: tokens += usage["total_tokens"] - return AgentUsage( + return schemas.AgentUsage( day=local_start.date().isoformat(), timezone=str(timezone), spend_today_usd=round(spend, 4), @@ -149,11 +134,11 @@ def get_usage(account: Account) -> AgentUsage: ) -def _harness_usage(name: str, account_id: str, *, now: datetime) -> AgentHarnessUsage: +def _harness_usage(name: str, account_id: str, *, now: datetime) -> schemas.AgentHarnessUsage: is_connected = bool(HarnessConnection.get_for_account(name, account_id)) row = UsageScrape.latest_for(name, account_id) if not row: - return AgentHarnessUsage(name=name, is_connected=is_connected) + return schemas.AgentHarnessUsage(name=name, is_connected=is_connected) history = UsageScrape.history_for(name, account_id, since=now - WEEK_RANGE) five_hour_cutoff = now - FIVE_HOUR_RANGE five_hour = [ @@ -166,7 +151,7 @@ def _harness_usage(name: str, account_id: str, *, now: datetime) -> AgentHarness for point in history if point.week_percent_left is not None ] - return AgentHarnessUsage( + return schemas.AgentHarnessUsage( name=name, is_connected=is_connected, plan_tier=row.plan_tier, diff --git a/backend/tests/test_agent_services.py b/backend/tests/test_agent_services.py index 2c91c39..f41f2f0 100644 --- a/backend/tests/test_agent_services.py +++ b/backend/tests/test_agent_services.py @@ -7,15 +7,7 @@ from druks.durable.models import Artifact, Run from druks.durable.reads import read_slice from druks.mcp.gateway import services -from druks.mcp.gateway.exceptions import ( - AgentCallNotFound, - GateNotAnswerable, - GateNotOpen, - GateRoundStale, - InvalidGateAnswer, - RunNotActive, - RunNotFound, -) +from druks.mcp.gateway import exceptions from druks.usage.models import UsageScrape pytestmark = pytest.mark.usefixtures("_data_dir") @@ -134,12 +126,12 @@ def test_get_gate_serves_the_artifact(db_session): def test_get_gate_refuses_when_not_parked_or_external(db_session): - with pytest.raises(RunNotFound): + with pytest.raises(exceptions.RunNotFound): services.get_gate("no-such-run") item = make_test_work_item(repo="o/r", title="t") running = seed_build_run(db_session, work_item_id=item.id, state="running") - with pytest.raises(GateNotOpen): + with pytest.raises(exceptions.GateNotOpen): services.get_gate(running.id) external_item = make_test_work_item(repo="o/r2", title="t") @@ -148,7 +140,7 @@ def test_get_gate_refuses_when_not_parked_or_external(db_session): external_item.id, ask={"presentation": "external", "label": "Answer on the ticket"}, ) - with pytest.raises(GateNotAnswerable): + with pytest.raises(exceptions.GateNotAnswerable): services.get_gate(external.id) @@ -186,21 +178,21 @@ async def test_answer_gate_uses_the_receipt_for_already_answered(db_session, res async def test_answer_gate_error_taxonomy(db_session, resume_spy): - with pytest.raises(RunNotFound): + with pytest.raises(exceptions.RunNotFound): await services.answer_gate( "no-such-run", parked_at=datetime.now(UTC), control="approve", answers={}, note="" ) item = make_test_work_item(repo="o/r", title="t") finished = seed_build_run(db_session, work_item_id=item.id, state="finished") - with pytest.raises(GateNotOpen): + with pytest.raises(exceptions.GateNotOpen): await services.answer_gate( finished.id, parked_at=datetime.now(UTC), control="approve", answers={}, note="" ) parked_item = make_test_work_item(repo="o/r2", title="t") run = _park(db_session, parked_item.id) - with pytest.raises(GateRoundStale): + with pytest.raises(exceptions.GateRoundStale): await services.answer_gate( run.id, parked_at=run.input_requested_at - timedelta(seconds=5), @@ -208,7 +200,7 @@ async def test_answer_gate_error_taxonomy(db_session, resume_spy): answers={}, note="", ) - with pytest.raises(InvalidGateAnswer): + with pytest.raises(exceptions.InvalidGateAnswer): await services.answer_gate( run.id, parked_at=run.input_requested_at, control="merge", answers={}, note="" ) @@ -219,7 +211,7 @@ async def test_answer_gate_error_taxonomy(db_session, resume_spy): external_item.id, ask={"presentation": "external", "label": "Answer on the ticket"}, ) - with pytest.raises(GateNotAnswerable): + with pytest.raises(exceptions.GateNotAnswerable): await services.answer_gate( external.id, parked_at=external.input_requested_at, @@ -256,7 +248,7 @@ def test_get_agent_call_serves_bounded_tails(db_session): assert detail.artifact is not None assert detail.artifact.content == "a" * 4096 - with pytest.raises(AgentCallNotFound): + with pytest.raises(exceptions.AgentCallNotFound): services.get_agent_call("no-such-call") @@ -290,10 +282,10 @@ async def test_cancel_run_paths(db_session): finished_item = make_test_work_item(repo="o/r2", title="t") finished = seed_build_run(db_session, work_item_id=finished_item.id, state="finished") - with pytest.raises(RunNotActive): + with pytest.raises(exceptions.RunNotActive): await services.cancel_run(finished.id, reason="late") - with pytest.raises(RunNotFound): + with pytest.raises(exceptions.RunNotFound): await services.cancel_run("no-such-run", reason="x") From 57bf786d33e00226664ffd695e8e6207fa230bbb Mon Sep 17 00:00:00 2001 From: Paulo Date: Mon, 20 Jul 2026 14:38:46 +0200 Subject: [PATCH 19/21] Sort the merged gateway import --- backend/tests/test_agent_services.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/tests/test_agent_services.py b/backend/tests/test_agent_services.py index f41f2f0..e8581bf 100644 --- a/backend/tests/test_agent_services.py +++ b/backend/tests/test_agent_services.py @@ -6,8 +6,7 @@ from druks.accounts.models import Account from druks.durable.models import Artifact, Run from druks.durable.reads import read_slice -from druks.mcp.gateway import services -from druks.mcp.gateway import exceptions +from druks.mcp.gateway import exceptions, services from druks.usage.models import UsageScrape pytestmark = pytest.mark.usefixtures("_data_dir") From b298278f4a792c4805dbc0e46523312d4770c0e7 Mon Sep 17 00:00:00 2001 From: Paulo Date: Mon, 20 Jul 2026 14:53:57 +0200 Subject: [PATCH 20/21] Name the gateway response bodies *Response, per house convention GateDetail/AgentCallDetail broke the *Response convention and GateAnswerResult/ CancelRunResult reused *Result (which is for internal domain results, not HTTP bodies). Now GateResponse, GateAnswerResponse, AgentCallDetailResponse, CancelRunResponse, AgentUsageResponse; the nested parts (AgentHarnessUsage, ArtifactContent) stay descriptive. --- backend/druks/mcp/gateway/routes.py | 20 ++++++++++---------- backend/druks/mcp/gateway/schemas.py | 10 +++++----- backend/druks/mcp/gateway/services.py | 24 ++++++++++++------------ 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/backend/druks/mcp/gateway/routes.py b/backend/druks/mcp/gateway/routes.py index f791a37..1a46a95 100644 --- a/backend/druks/mcp/gateway/routes.py +++ b/backend/druks/mcp/gateway/routes.py @@ -12,20 +12,20 @@ @router.get( "/gates/{run_id}", operation_id="get_gate", - response_model=schemas.GateDetail, + response_model=schemas.GateResponse, response_model_by_alias=True, ) -async def get_gate(run_id: str) -> schemas.GateDetail: +async def get_gate(run_id: str) -> schemas.GateResponse: return services.get_gate(run_id) @router.post( "/gates/{run_id}/answer", operation_id="answer_gate", - response_model=schemas.GateAnswerResult, + response_model=schemas.GateAnswerResponse, response_model_by_alias=True, ) -async def answer_gate(run_id: str, body: schemas.AnswerGateRequest) -> schemas.GateAnswerResult: +async def answer_gate(run_id: str, body: schemas.AnswerGateRequest) -> schemas.GateAnswerResponse: return await services.answer_gate( run_id, parked_at=body.parked_at, @@ -38,30 +38,30 @@ async def answer_gate(run_id: str, body: schemas.AnswerGateRequest) -> schemas.G @router.get( "/agent-calls/{call_id}", operation_id="get_agent_call", - response_model=schemas.AgentCallDetail, + response_model=schemas.AgentCallDetailResponse, response_model_by_alias=True, ) -async def get_agent_call(call_id: str) -> schemas.AgentCallDetail: +async def get_agent_call(call_id: str) -> schemas.AgentCallDetailResponse: return services.get_agent_call(call_id) @router.post( "/runs/{run_id}/cancel", operation_id="cancel_run", - response_model=schemas.CancelRunResult, + response_model=schemas.CancelRunResponse, response_model_by_alias=True, ) async def cancel_run( run_id: str, reason: Annotated[str, Body(embed=True, min_length=1, max_length=500)] -) -> schemas.CancelRunResult: +) -> schemas.CancelRunResponse: return await services.cancel_run(run_id, reason=reason) @router.get( "/usage", operation_id="get_usage", - response_model=schemas.AgentUsage, + response_model=schemas.AgentUsageResponse, response_model_by_alias=True, ) -async def get_usage(account: Account = Depends(current_account)) -> schemas.AgentUsage: +async def get_usage(account: Account = Depends(current_account)) -> schemas.AgentUsageResponse: return services.get_usage(account) diff --git a/backend/druks/mcp/gateway/schemas.py b/backend/druks/mcp/gateway/schemas.py index fbba555..95e6ffe 100644 --- a/backend/druks/mcp/gateway/schemas.py +++ b/backend/druks/mcp/gateway/schemas.py @@ -16,7 +16,7 @@ class ArtifactContent(BaseResponse): content: str -class GateDetail(BaseResponse): +class GateResponse(BaseResponse): # parked_at is the park identity answer_gate must echo back. run_id: str gate: str @@ -25,7 +25,7 @@ class GateDetail(BaseResponse): artifact: ArtifactContent | None = None -class GateAnswerResult(BaseResponse): +class GateAnswerResponse(BaseResponse): run_id: str parked_at: datetime result: Literal["answered", "already_answered"] @@ -40,7 +40,7 @@ class AnswerGateRequest(BaseModel): note: str = "" -class AgentCallDetail(BaseResponse): +class AgentCallDetailResponse(BaseResponse): run_id: str call: AgentCallResponse transcript: str @@ -48,7 +48,7 @@ class AgentCallDetail(BaseResponse): artifact: ArtifactContent | None = None -class CancelRunResult(BaseResponse): +class CancelRunResponse(BaseResponse): run_id: str result: Literal["cancelled", "already_cancelled"] @@ -68,7 +68,7 @@ class AgentHarnessUsage(BaseResponse): week_history: list[UsageHistoryPoint] = Field(default_factory=list) -class AgentUsage(BaseResponse): +class AgentUsageResponse(BaseResponse): day: str timezone: str spend_today_usd: float diff --git a/backend/druks/mcp/gateway/services.py b/backend/druks/mcp/gateway/services.py index 869984f..6ab2302 100644 --- a/backend/druks/mcp/gateway/services.py +++ b/backend/druks/mcp/gateway/services.py @@ -25,7 +25,7 @@ _HISTORY_POINTS = 8 -def get_gate(run_id: str) -> schemas.GateDetail: +def get_gate(run_id: str) -> schemas.GateResponse: run = Run.get(run_id) if not run: raise exceptions.RunNotFound(run_id) @@ -34,7 +34,7 @@ def get_gate(run_id: str) -> schemas.GateDetail: ask = run.input_request if not ask or ask.get("presentation") != "in_app": raise exceptions.GateNotAnswerable(run_id) - return schemas.GateDetail( + return schemas.GateResponse( run_id=run.id, gate=run.input_gate, # type: ignore[arg-type] parked_at=run.input_requested_at, # type: ignore[arg-type] @@ -45,13 +45,13 @@ def get_gate(run_id: str) -> schemas.GateDetail: async def answer_gate( run_id: str, *, parked_at: datetime, control: str, answers: dict[str, str], note: str -) -> schemas.GateAnswerResult: +) -> schemas.GateAnswerResponse: run = Run.get(run_id) if not run: raise exceptions.RunNotFound(run_id) db_session().expire(run) # the receipt/park comparison must read fresh if run.answer_parked_at == parked_at: - return schemas.GateAnswerResult( + return schemas.GateAnswerResponse( run_id=run.id, parked_at=parked_at, result="already_answered" ) if run.state != RunState.PENDING_INPUT.value: @@ -66,15 +66,15 @@ async def answer_gate( except InvalidChoiceError as error: raise exceptions.InvalidGateAnswer(str(error)) from error await run.resume(**payload) - return schemas.GateAnswerResult(run_id=run.id, parked_at=parked_at, result="answered") + return schemas.GateAnswerResponse(run_id=run.id, parked_at=parked_at, result="answered") -def get_agent_call(call_id: str) -> schemas.AgentCallDetail: +def get_agent_call(call_id: str) -> schemas.AgentCallDetailResponse: call = AgentCall.get(call_id) if not call: raise exceptions.AgentCallNotFound(call_id) layout = call.artifact_layout - return schemas.AgentCallDetail( + return schemas.AgentCallDetailResponse( run_id=call.run_id, call=AgentCallResponse.from_call(call), transcript=read_slice( @@ -85,16 +85,16 @@ def get_agent_call(call_id: str) -> schemas.AgentCallDetail: ) -async def cancel_run(run_id: str, *, reason: str) -> schemas.CancelRunResult: +async def cancel_run(run_id: str, *, reason: str) -> schemas.CancelRunResponse: run = Run.get(run_id) if not run: raise exceptions.RunNotFound(run_id) if run.state == RunState.CANCELLED.value: - return schemas.CancelRunResult(run_id=run.id, result="already_cancelled") + return schemas.CancelRunResponse(run_id=run.id, result="already_cancelled") if not run.is_active: raise exceptions.RunNotActive(run_id) await run.cancel(failure=reason) - return schemas.CancelRunResult(run_id=run.id, result="cancelled") + return schemas.CancelRunResponse(run_id=run.id, result="cancelled") def _artifact_content(artifact: Artifact | None) -> schemas.ArtifactContent | None: @@ -112,7 +112,7 @@ def _artifact_content(artifact: Artifact | None) -> schemas.ArtifactContent | No ) -def get_usage(account: Account) -> schemas.AgentUsage: +def get_usage(account: Account) -> schemas.AgentUsageResponse: now = datetime.now(UTC) timezone, local_start = operator_local_day(UserSettings.get().timezone, now) rows = list_finished_calls(account.id, since=local_start, until=local_start + timedelta(days=1)) @@ -124,7 +124,7 @@ def get_usage(account: Account) -> schemas.AgentUsage: usage = normalize_token_usage(cost_metadata) if usage: tokens += usage["total_tokens"] - return schemas.AgentUsage( + return schemas.AgentUsageResponse( day=local_start.date().isoformat(), timezone=str(timezone), spend_today_usd=round(spend, 4), From f29921f2eb9c4dd9c1210043ff17fe50b52da19a Mon Sep 17 00:00:00 2001 From: Paulo Date: Mon, 20 Jul 2026 15:23:14 +0200 Subject: [PATCH 21/21] Rename derived_status to AgentCall.get_live_status() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit derived_status named the mechanism, not the thing. It's the call's live status — running while its run is alive, abandoned if the run died under it, else its recorded outcome. A method (it reaches through the run relationship), named for what it returns. --- backend/druks/durable/models.py | 3 +-- backend/druks/durable/schemas.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/backend/druks/durable/models.py b/backend/druks/durable/models.py index 227ed1d..4529472 100644 --- a/backend/druks/durable/models.py +++ b/backend/druks/durable/models.py @@ -252,8 +252,7 @@ class AgentCall(Base, Uuid7Pk): # and treat 404 as the host being gone. sandbox_host_id: Mapped[str] - @property - def derived_status(self) -> str: + def get_live_status(self) -> str: # Unfinished: "running" while the run is live, "abandoned" once it's terminal. if self.finished_at: return AgentCallStatus(self.status).value diff --git a/backend/druks/durable/schemas.py b/backend/druks/durable/schemas.py index 53a2f8e..917dd5f 100644 --- a/backend/druks/durable/schemas.py +++ b/backend/druks/durable/schemas.py @@ -53,7 +53,7 @@ def from_call(cls, call: AgentCall) -> "AgentCallResponse": agent=call.agent, label=get_display_label(call.agent) if call.agent else "Agent", account_username=call.account.username, - status=call.derived_status, # type: ignore[arg-type] + status=call.get_live_status(), # type: ignore[arg-type] started_at=call.started_at, finished_at=call.finished_at, last_error=call.last_error,