Agent services, agent-tagged routes, and the gate answer receipt#36
Merged
Conversation
This was referenced Jul 19, 2026
czpython
force-pushed
the
eng/agent-services
branch
from
July 19, 2026 15:55
0d91b92 to
629eb65
Compare
czpython
marked this pull request as ready for review
July 20, 2026 13:35
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
- 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.
_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.
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.
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.
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.
czpython
force-pushed
the
eng/agent-services
branch
from
July 20, 2026 13:35
9765415 to
f29921f
Compare
czpython
added a commit
that referenced
this pull request
Jul 20, 2026
#36 shipped the slice tests without the machinery they assert: a window seam mid-character served a replacement char and the free transcript route disagreed with its own expectations. Ported from the original branch: continuation-prefix and partial-suffix trimming (RFC 3629 ranges so only sequences that can still complete are trimmed), a one-character limit floor so pagination always progresses, and the behavior tests.
czpython
added a commit
that referenced
this pull request
Jul 20, 2026
The #36 review stripped the boundary-snapping machinery (keep it simple until it's an issue); the seam serves one replacement char, same as the live tail. This aligns the one test expectation main still carried from before that call — eng/agent-services' fuller sweep supersedes it — and drops the guide's clipping claims.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
T3 of the MCP arc (spec: tmp/mcp-arc-tickets.md), stacked on #34 + #35 (their commits are in this diff until they merge; review this PR for the T3 commits 8a55470/847af68e/55b593e3 + merge glue). The eight agent-facing operations land as services on their owning packages (build/agent.py, durable/agent.py, usage/agent.py) with thin
tags=["agent"]routes, a closed error taxonomy serialized as one{code, message, retryable}shape, and size budgets asserted as tests. The gate answer receipt isdurable_runs.answered_parked_at, written SQL-side (SET answered_parked_at = input_requested_at) inside_park's existing RUNNING transition step on the answer path only — replay writes the same stamp, cancel/timeout never set it.answer_gateechoesparked_at, reuses Run.resume's idempotency, and reads the receipt first so a late duplicate answer saysalready_answered. Existing resume/transcript/board/usage routes are refactored onto the same services with HTTP/service parity tests; the transcript reader gained one shared UTF-8-saferead_slice.Test evidence: new test_gate_receipt.py (answer stamps; cancel/timeout don't), test_agent_services.py, test_agent_routes.py incl. serialized-size budget assertions and the preserved resume-route contract; ruff clean; pyright = main baseline; backend suite runs in CI.
Calls for review attention: (1) BuildWorkflow.dispatch now returns WorkflowStartResult (evolves T2's
-> str— only way to carry is_duplicate through the launch policy without a bypass); (2) shareddownsamplenow honors its cap strictly — dashboard sparklines can differ by one sample; (3) dispatch never creates work items — (source, ticket_ref) resolves existing only, intake stays webhook-owned; (4) test_resume.py seeds gained the canonical park fields rather than keeping the route lenient to presentation-less asks.