Skip to content

feat(examples): dogfood the durable agent runtime in examples/agents (RFC 0017 Part 4a) - #692

Merged
7nohe merged 1 commit into
mainfrom
feat/rfc0017-part4a-guren-dev-agent
Sep 6, 2026
Merged

feat(examples): dogfood the durable agent runtime in examples/agents (RFC 0017 Part 4a)#692
7nohe merged 1 commit into
mainfrom
feat/rfc0017-part4a-guren-dev-agent

Conversation

@7nohe

@7nohe 7nohe commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Summary

RFC 0017 Part 4a: a standalone demo app, examples/agents (@guren/example-agents), that dogfoods the durable agent runtime end to end — and the framework fixes the dogfooding found. The app is deployed to a Workers Free plan account (its own Worker and D1, nothing shared with guren.dev) and the walkthrough was run against it; the README carries the measured numbers.

The demo

  • Triager (app/Agents/Triager.ts): an hourly cron sweep lists open tickets through tickets.index, and for every ticket older than seven days calls tickets.close, which is approval: 'required' — the call parks, the Part 3 ledger retries it once a human approves, onToolApprovalSettled records the outcome and remembers a rejection so the agent does not ask again. Public RPC sweep() / report().
  • Tools on routes: GET /tickets (read-only, zod output) and POST /tickets/:id/close behind requireAuthenticated() + authorizeMiddleware('close-ticket'). One Gate rule admits operators (a users row, numeric id) and the seam principal agent:triager:* (string id); guren check requires the authorization on a mutating tool, and the seam deliberately does not satisfy createBearerTokenMiddleware.
  • DrizzleApprovalStore: the first AgentApprovalStore implementation in the repo — findMatch newest-unconsumed, consume as a compare-and-set UPDATE … WHERE consumed_at IS NULL … RETURNING, portable across bun:sqlite and D1. Generic enough to become a framework default later (not moved here).
  • Operator API under /approvals and /ops/agents/triager/*, bearer tokens (DrizzleApiTokenStore), gated by an operate ability so the agent principal can never resolve an approval; /ops/agents/… because the generated worker reserves /agents/* for the SDK router (kept deny-all — the app talks to its agent through the TRIAGER binding).
  • sqlite locally / D1 on Workers, migrations, a re-runnable seeder (prints the operator token once), db/seed-d1.ts for D1, 11 Bun tests, README with the local wrangler dev transcript and the Free-plan measurements.

Framework fixes (changesets)

  • @guren/plugin-agents minor: onToolApprovalSettled now carries args (absent only for an 'unreadable' row). Without it an app had to keep its own requestId → arguments map beside the ledger's — which already decrypts them for the retry. The sweep asks the queue about a row that has passed its expiry before settling it, so a human who rejected in the last backoff window is reported rejected, not expired (an unanswerable lapsed row is dropped as expired rather than kept forever). README status blockquote corrected (the ledger shipped in Part 3) and ok documented as "dispatched", outcome.isError as the verdict.
  • @guren/plugin-cloudflare minor: isWorkersRuntime() exported from @guren/plugin-cloudflare/env; every app and both docs guides had hand-written the same three lines.
  • @guren/cli patch: make:agent's config/agents.ts comment trimmed to the guren/comment-length rule scaffolded apps now install.

What the Free plan run measured

Event CPU Wall
Worker startup 97–103 ms bundle 568 KB gzip
Worker request, warm isolate 4 ms 80 ms
Worker request that boots the app (cold isolate) 20–31 ms ~110 ms
Durable Object sweep (boot + 2 tool calls + 2 approval records) 47 ms 179 ms
Ledger alarm (retry, close, settle) 14 ms 129 ms

The alarm fired 30 s after the calls parked with no request touching the Worker, and the retry closed the ticket; the walkthrough was repeated after each review round (four deploys). The demo stays inside the Free plan's 50 D1 queries per invocation: a sweep asks at most 10 new tickets (1 + 2×10 = 21 queries), skips tickets already parked, and reports the rest as deferred. Cold-boot requests exceed the Free plan's stated 10 ms per invocation and were tolerated (outcome: ok); the README says what to watch and when the Paid plan removes the ceiling.

Found in production, fixed here

  • A Durable Object's state does not migrate with the code. initialState seeds a new instance only; the deployed Triager kept the previous deploy's state shape and the first sweep after a field was added threw Cannot convert undefined or null to object. The demo layers initialState under this.state on every read; the RFC records it as the runtime half of Open Question 5 (a migrateState hook or GurenAgent applying defaults itself).
  • result.ok is dispatch, not success: a tickets.index that answers HTTP 5xx is a blind sweep (error in the summary), a failed close counts as refused, and a settled retry reports retried: 'ok' | 'failed'.
  • Expired approvals were listed as pending and answerable; the operator API now derives status for pending rows, refuses a re-answer or a lapsed request with 409, bounds both lists, and offers POST /approvals/prune.

Recorded for follow-up (not fixed here)

  • guren audit and guren tool:list default to routes/web.ts without probing for routes/api.ts as guren check does; audit then warns, skips every route-level check, and exits 0. The example passes --routes routes/api.ts; the gate itself wants its own PR with tests.
  • make:agent output does not typecheck in a fresh app (Env undeclared, @cloudflare/workers-types not mentioned); the plugin-agents README says the command "writes all three" files when it writes two; guren check's body-schema warning fires on a POST tool whose action validates only params.

Verification

Example: typecheck, test (11), guren check (10 passed / 4 warnings), guren audit --routes routes/api.ts (21 passed), cloudflare:build, wrangler dev --local walkthrough, deployed walkthrough on the Free plan. Root: bun run lint, bun run typecheck, test:bun plugin-agents plugin-cloudflare, test:agents (32 workerd), cli suite without --isolate, audit:core-first audit:docs audit:workspace-scripts audit:agent-catalog audit:plugin-compat.

@7nohe
7nohe force-pushed the feat/rfc0017-part4a-guren-dev-agent branch from 6a29640 to 5a2f9c2 Compare September 6, 2026 05:49
…(RFC 0017 Part 4a)

A standalone demo app with one real durable agent, driven end to end under
`wrangler dev --local`: a Triager sweeps stale tickets, parks a close behind
the approval queue, and the pending-approval ledger retries it on its own
alarm once a human approves.

The app exercises the whole of Parts 1-3 and the Part 2b build wiring: two
`.agent()` routes, an `AgentApprovalStore` over a table, an operator API, the
generated worker's named export and guarded `/agents/*` mount, and the
bindings verifier's copy-pasteable JSON.

Three framework fixes came out of it:

- `onToolApprovalSettled` now carries the parked call's `args`. The queue
  keeps no reversible copy by design, so an application had to hold a second
  `requestId` -> arguments map beside the ledger's own to know which call a
  human answered.
- `isWorkersRuntime()` is exported from `@guren/plugin-cloudflare/env`. Every
  app hand-wrote it in `config/database.ts`, and the package README already
  used the name with no import.
- `make:agent`'s `config/agents.ts` comment fits the `guren/comment-length`
  rule a scaffolded app installs, so `make:agent` followed by `bun run lint`
  no longer fails on the scaffolder's own output.
@7nohe
7nohe force-pushed the feat/rfc0017-part4a-guren-dev-agent branch from 5a2f9c2 to fa64404 Compare September 6, 2026 05:57
@7nohe
7nohe merged commit 7fafa9f into main Sep 6, 2026
8 checks passed
@7nohe
7nohe deleted the feat/rfc0017-part4a-guren-dev-agent branch September 6, 2026 06:03
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