Skip to content

Agent services, agent-tagged routes, and the gate answer receipt#36

Merged
czpython merged 21 commits into
mainfrom
eng/agent-services
Jul 20, 2026
Merged

Agent services, agent-tagged routes, and the gate answer receipt#36
czpython merged 21 commits into
mainfrom
eng/agent-services

Conversation

@czpython

Copy link
Copy Markdown
Owner

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 is durable_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_gate echoes parked_at, reuses Run.resume's idempotency, and reads the receipt first so a late duplicate answer says already_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-safe read_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) shared downsample now 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.

czpython added 21 commits July 20, 2026 20: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
czpython force-pushed the eng/agent-services branch from 9765415 to f29921f Compare July 20, 2026 13:35
@czpython
czpython merged commit 3a7bac8 into main Jul 20, 2026
1 check failed
@czpython
czpython deleted the eng/agent-services branch July 20, 2026 13:36
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant