Agent runtime: majordomo in-process, Ollama Cloud config, chat endpoint (#56) #70
@@ -113,7 +113,16 @@ implemented in later per-issue sessions.
|
||||
the `_PATH`/`_ADDR` names you might guess. Authentik is the primary IdP;
|
||||
OIDC-first with local passwords as fallback.
|
||||
|
||||
Agent config (v2): `OLLAMA_CLOUD_API_KEY` and `PANSY_AGENT_MODEL` (default
|
||||
`ollama-cloud/glm-5.2:cloud`), set in Komodo. Model strings pass verbatim to
|
||||
`majordomo.Parse`, so a comma-separated spec gives failover for free.
|
||||
`majordomo` and `executus` are sibling repos at `../`.
|
||||
Agent config: `OLLAMA_CLOUD_API_KEY`, `PANSY_AGENT_MODEL` (default
|
||||
`ollama-cloud/glm-5.2:cloud`) and `PANSY_AGENT_ENABLED`, set in Komodo. Model
|
||||
strings pass verbatim to `majordomo.Parse`, so a comma-separated spec gives
|
||||
failover for free — don't parse that grammar in pansy.
|
||||
|
||||
`majordomo` is a **real dependency** now, resolved from the Gitea instance as a
|
||||
pseudo-version. There is no `replace` directive and there must not be one: a
|
||||
`replace` pointing at `../majordomo` builds on your laptop and breaks the Docker
|
||||
build, which has no sibling checkout. `executus` is a sibling repo at `../` and
|
||||
is not a dependency.
|
||||
|
||||
The `majordomo` build tag is **gone**. Don't reintroduce it — an untagged CI that
|
||||
never compiles the agent is worse than a slightly larger binary.
|
||||
|
||||
@@ -23,6 +23,7 @@ SQLite, centimeters everywhere (display-side imperial conversion only), `version
|
||||
- **garden_objects** — one polymorphic table: `kind` (`bed`|`grow_bag`|`container`|`in_ground`|`tree`|`path`|`structure`), name, `shape` (`rect`|`circle`; `polygon` + `points` JSON reserved in schema, not in the v1 editor), center `x_cm`,`y_cm` in garden space, `width_cm`,`height_cm` (circle: width=diameter), `rotation_deg`, `z_index`, `plantable` bool, nullable `color` override, `props` JSON for kind-specific extras (bed height, tree species…), notes.
|
||||
- **seed_lots** — one purchase: vendor, source URL, sku, lot code, purchase date, packed-for year, quantity + unit, cost, germination %. `owner_id` is on the LOT, not inherited from the plant, so you can record buying generic built-in Garlic from Johnny's and keep it private. `plantings.seed_lot_id` (ON DELETE SET NULL) attributes a plop to a purchase; **`remaining` is derived** (quantity − summed effective count of active plantings), never stored — a decremented column drifts the moment a planting is edited behind its back and can't be audited afterwards. Lots are never shared along with a garden.
|
||||
- **plants** — catalog, plus `source_url`/`vendor` provenance for the variety itself. `owner_id NULL` = built-in (~30 seeded via migration, read-only, clone to customize). name, category (`vegetable`|`herb`|`flower`|`fruit`|`tree_shrub`|`cover`), `spacing_cm` (mature spacing), `color` hex, `icon` (emoji string — zero assets in v1), nullable `days_to_maturity`, notes. Users see built-ins + their own.
|
||||
- **agent_conversations** / **agent_messages** — the assistant's chat, one thread per (user, garden). Only the user/assistant TEXT of each turn is stored, not the model's full transcript: continuity needs what was said and what came back, and replaying a stored tool call would replay a decision made against a garden that has since moved on. `agent_messages.change_set_id` is the handle the chat panel uses to offer Undo on the turn that caused it.
|
||||
- **journal_entries** — the grow log: `garden_id` (NOT NULL), nullable `object_id`/`planting_id` to narrow the target, author, body, `observed_at`. `notes` on a garden/object/plant says what the thing IS and a new note overwrites the old; a journal entry says what HAPPENED and when, and entries accumulate. `garden_id` is set even for a bed- or plop-level entry so permission checks reuse `requireGardenRole` unchanged rather than inventing a second ACL path. `observed_at` is distinct from `created_at` — you write up Saturday's observations on Sunday. Deliberately **not** in the revision history: entries are already append-shaped and individually versioned.
|
||||
- **change_sets** / **revisions** — the undo substrate. A change set groups the row-level revisions one logical operation produced (`source` ui/agent/api, `summary`, nullable `agent_run_id`, nullable `reverts_id`); a revision is `(entity_type, entity_id, op, before, after)` with full JSON row snapshots. The unit of undo is the **operation, not the row**. Revert is itself a change set, so an undo can be undone; it is version-guarded per entity and reports conflicts rather than clobbering a later edit. Garden *deletion* is deliberately not revertible (the cascade is invisible to the service, and would take the history with it).
|
||||
- **plantings** ("plops") — object_id, plant_id, center `x_cm`,`y_cm` **in the parent object's local frame** (moving/rotating a bed moves its plants for free), `radius_cm` (a plop is a circular patch), nullable `count` (NULL = derived: `max(1, round(π·r² / spacing_cm²))`), nullable label, `planted_at`/`removed_at` dates. "Clear bed" sets `removed_at`; v1 shows rows where `removed_at IS NULL`. Year-over-year history and a future plan/scenario layer build on these dates without schema surgery.
|
||||
@@ -64,6 +65,10 @@ POST /gardens/:id/objects PATCH,DELETE /objects/:id
|
||||
POST /objects/:id/plantings PATCH,DELETE /plantings/:id
|
||||
GET,POST /plants PATCH,DELETE /plants/:id (own plants only)
|
||||
GET,POST /seed-lots GET,PATCH,DELETE /seed-lots/:id (own lots only; private)
|
||||
GET,POST /gardens/:id/journal PATCH,DELETE /journal/:id (editor writes; author edits own)
|
||||
GET /gardens/:id/journal/counts ← entries per object, for the "has notes" indicator
|
||||
POST /agent/chat ← SSE: step events, then the finished turn (editor only)
|
||||
GET,DELETE /gardens/:id/agent/history (the actor's own thread)
|
||||
GET,POST /gardens/:id/shares PATCH,DELETE /gardens/:id/shares/:userId (invite by email)
|
||||
```
|
||||
|
||||
@@ -77,6 +82,7 @@ GET,POST /gardens/:id/shares PATCH,DELETE /gardens/:id/shares/:userId (invit
|
||||
cmd/pansy/main.go config load → store.Open → migrate → service → api → ListenAndServe
|
||||
internal/config/ PANSY_PORT, PANSY_DB, PANSY_BASE_URL, PANSY_REGISTRATION,
|
||||
PANSY_OIDC_* (issuer/client_id/secret/button label), PANSY_LOCAL_AUTH,
|
||||
PANSY_AGENT_MODEL / OLLAMA_CLOUD_API_KEY / PANSY_AGENT_ENABLED,
|
||||
trusted proxies
|
||||
internal/store/ sqlite.go (open/pragmas), migrate.go, migrations/*.sql (embedded),
|
||||
queries per entity (users.go, gardens.go, objects.go, plantings.go,
|
||||
@@ -89,7 +95,8 @@ internal/service/ ★ THE SEAM. All permission checks + business ops. aut
|
||||
internal/api/ thin gin handlers (decode → service → encode), session middleware,
|
||||
ginserver setup, routes.go, oidc.go, spa.go (embed fallback)
|
||||
internal/webdist/ dist.go: //go:embed all:dist (web build copied in by Makefile)
|
||||
internal/agent/ later: llm.DefineTool[Args] wrappers over the SAME service methods
|
||||
internal/agent/ tools.go (llm.DefineTool wrappers over the SAME service methods),
|
||||
runtime.go (model resolution, run loop, one change set per turn)
|
||||
web/ Vite app
|
||||
Makefile (cd web && npm run build) → copy dist → CGO_ENABLED=0 go build
|
||||
```
|
||||
@@ -123,7 +130,8 @@ React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/reac
|
||||
6. **Plops** — focus-zoom, place/move/resize, derived counts, semantic zoom. *The core vision lands here.*
|
||||
7. **Sharing** — invite by email, roles, viewer read-only mode.
|
||||
8. **Polish** — imperial toggle, mobile ergonomics, clear-bed, keyboard nudging.
|
||||
9. **Agent seam** — `ops.go` bulk ops + `internal/agent` DefineTool wrappers; optional mort-style API-key agent endpoint. The agent harness itself lives in majordomo/executus, outside pansy.
|
||||
9. **Agent seam** — `ops.go` bulk ops + `internal/agent` DefineTool wrappers.
|
||||
10. **Garden assistant** — majordomo in-process, Ollama Cloud, streaming chat. Each turn runs inside ONE change set (`source='agent'`), so a turn that clears a bed and replants it undoes as one action; that is what makes acting without a confirmation prompt defensible. Bounded by a step cap and a timeout — loop safety, not spend control. The `majordomo` build tag is gone: a tag that keeps the agent out of the binary only earns its keep if you'd ship a build without it, and the agent is the point.
|
||||
|
||||
## Deliberate v1 limits
|
||||
|
||||
|
||||
@@ -63,12 +63,15 @@ All configuration is via environment variables; every value has a default, so `.
|
||||
| `PANSY_OIDC_BUTTON_LABEL` | `Sign in with Authentik` | Label for the OIDC button on the login page. |
|
||||
| `PANSY_TRUSTED_PROXIES` | *(none)* | Comma-separated proxy CIDRs/IPs to trust for client-IP resolution. |
|
||||
|
||||
The garden assistant reads two more. Both are **read only once the assistant lands** ([#56](https://gitea.stevedudenhoeffer.com/steve/pansy/issues/56)); setting them earlier is harmless and setting neither leaves the assistant off.
|
||||
The garden assistant reads three more. Setting none of them leaves the assistant off; the app runs exactly as it does without it.
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ----------------------- | ----------------------------- | --------------------------------------------------------------------------- |
|
||||
| `OLLAMA_CLOUD_API_KEY` | *(empty)* | Ollama Cloud API key. Without it the assistant is disabled, not broken. |
|
||||
| `PANSY_AGENT_MODEL` | `ollama-cloud/glm-5.2:cloud` | Model spec, passed verbatim to `majordomo.Parse` — a comma-separated list is a failover chain. |
|
||||
| Variable | Default | Description |
|
||||
| ----------------------- | ------------------------------ | --------------------------------------------------------------------------- |
|
||||
| `OLLAMA_CLOUD_API_KEY` | *(empty)* | Ollama Cloud API key. Without it the assistant is disabled, not broken — the chat routes simply aren't registered. |
|
||||
| `PANSY_AGENT_MODEL` | `ollama-cloud/glm-5.2:cloud` | Model spec, passed verbatim to `majordomo.Parse` — a comma-separated list is a failover chain, e.g. `ollama-cloud/glm-5.2:cloud,ollama-cloud/kimi-k2.6:cloud`. |
|
||||
| `PANSY_AGENT_ENABLED` | on when a key is present | Turns the assistant off without removing the key. |
|
||||
|
||||
The assistant acts without asking first, which is only reasonable because every turn is one undoable change set — see the History panel in the editor.
|
||||
|
||||
Local email/password auth is live (`POST /api/v1/auth/register`, `/auth/login`, `/auth/logout`, `GET /auth/me`, `GET /auth/providers`); the session is an HttpOnly cookie (`Secure` when `PANSY_BASE_URL` is https). The first account registered becomes admin, and it may register even when `PANSY_REGISTRATION=closed` to bootstrap the instance.
|
||||
|
||||
|
||||
@@ -3,14 +3,30 @@ module gitea.stevedudenhoeffer.com/steve/pansy
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260718232210-a941f5ff4a3f
|
||||
github.com/coreos/go-oidc/v3 v3.20.0
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/samber/slog-gin v1.15.0
|
||||
golang.org/x/crypto v0.31.0
|
||||
golang.org/x/crypto v0.36.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
modernc.org/sqlite v1.34.4
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.116.0 // indirect
|
||||
cloud.google.com/go/auth v0.9.3 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.5.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/google/s2a-go v0.1.8 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
google.golang.org/genai v1.59.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
|
||||
google.golang.org/grpc v1.66.2 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.9 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
@@ -40,9 +56,9 @@ require (
|
||||
go.opentelemetry.io/otel v1.29.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.29.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/net v0.33.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
golang.org/x/net v0.38.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
|
||||
cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
|
||||
cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U=
|
||||
cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk=
|
||||
cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=
|
||||
cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY=
|
||||
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260718232210-a941f5ff4a3f h1:Zt91pngDr+TIU14sxv3fk1QS7bMQetvccFNg8kPaltA=
|
||||
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260718232210-a941f5ff4a3f/go.mod h1:UZLveG17SmENt4sne2RSLIbioix30RZbRIQUzBAnOyY=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/bytedance/sonic v1.11.9 h1:LFHENlIY/SLzDWverzdOvgMztTxcfcF+cqNsz9pK5zg=
|
||||
github.com/bytedance/sonic v1.11.9/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
|
||||
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -13,6 +26,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
|
||||
github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
@@ -31,13 +48,40 @@ github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4
|
||||
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
|
||||
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
@@ -65,6 +109,7 @@ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/samber/slog-gin v1.15.0 h1:Kqs/ilXd9divtslWjbz5DVptmLlzyntbBiXUAta2SFg=
|
||||
@@ -85,6 +130,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
||||
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
|
||||
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
|
||||
@@ -92,24 +139,77 @@ go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+M
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genai v1.59.0 h1:xp+ydkJFW8hO0hTUaAkr8TrLM9HFP3NYAwFhPd0nDqA=
|
||||
google.golang.org/genai v1.59.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo=
|
||||
google.golang.org/grpc v1.66.2/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -118,6 +218,8 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||
|
||||
+32
-14
@@ -1,18 +1,36 @@
|
||||
// Package agent adapts pansy's service layer to majordomo tools so an agent can
|
||||
// drive a garden in natural language ("fill the NE corner with garlic, the NW
|
||||
// with basil, the south half with beans"). Each tool runs as a fixed actor and
|
||||
// therefore inherits pansy's ACL checks for free — a viewer's fill_region returns
|
||||
// ErrForbidden, exactly as the REST API would.
|
||||
// Package agent is pansy's garden assistant: a majordomo toolbox over the
|
||||
// service layer, plus the run loop that drives a model with it.
|
||||
//
|
||||
// Two deliberate separations keep the core server lean:
|
||||
// Every tool runs as a fixed actor, so the agent inherits pansy's permission
|
||||
// checks for free — a viewer's fill_region returns ErrForbidden, exactly as the
|
||||
// REST API would. That is the whole reason the tools are thin adapters over
|
||||
// internal/service and never reach past it; the moment one does, the ACL story
|
||||
// stops being automatic and becomes something to remember.
|
||||
//
|
||||
// - cmd/pansy does NOT import this package, so the server binary carries no
|
||||
// agent/LLM dependencies.
|
||||
// - the tool wiring (tools.go) sits behind the `majordomo` build tag, so the
|
||||
// default `go build ./...` / `go test ./...` compiles without the majordomo
|
||||
// module. Build the agent tools with `-tags majordomo` once majordomo is a
|
||||
// dependency (`go get gitea.stevedudenhoeffer.com/steve/majordomo`).
|
||||
// # A turn is one change set
|
||||
//
|
||||
// The agent harness itself (model loop, chat surface) lives in the
|
||||
// majordomo/executus stack, outside this repo; this package is only the toolbox.
|
||||
// Runs wrap their whole turn in a single change set (source "agent"), so
|
||||
// "empty the garlic bed and plant cucumbers" — one object edit and a dozen
|
||||
// planting inserts — undoes as one action rather than thirteen. That is what
|
||||
// makes acting without a confirmation prompt defensible: the answer to "it did
|
||||
// the wrong thing" is one click, not an archaeology exercise.
|
||||
//
|
||||
// # No build tag
|
||||
//
|
||||
// This package used to sit behind a `majordomo` build tag, with cmd/pansy
|
||||
// deliberately not importing it, so the default build carried no LLM
|
||||
// dependency. Both separations are gone, on purpose: a tag that keeps the agent
|
||||
// out of the binary only earns its keep if you would ever ship a build without
|
||||
// the agent, and the agent is the point. Keeping it would have meant an
|
||||
// untagged CI that never compiled the code that matters.
|
||||
//
|
||||
// majordomo is stdlib-first and pure Go, so CGO_ENABLED=0 and the single static
|
||||
// binary survive it.
|
||||
//
|
||||
// # Unconfigured instances
|
||||
//
|
||||
// With no API key the assistant is simply not offered: the chat route isn't
|
||||
// registered and the capability isn't advertised — the same shape as OIDC
|
||||
// 404ing when unconfigured. An instance without a key starts and serves the app
|
||||
// exactly as it did before.
|
||||
package agent
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/agent"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/ollama"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||
)
|
||||
|
||||
// Loop bounds. These exist so a stuck run terminates, NOT to control spend —
|
||||
// pansy is a personal tool and multi-tenant cost control is explicitly not a
|
||||
// concern here. A model that gets wedged calling describe_garden forever should
|
||||
// stop on its own, and a hung upstream shouldn't hold a connection open all day.
|
||||
const (
|
||||
maxSteps = 24
|
||||
// A turn that legitimately fills several beds does a lot of round trips, so
|
||||
// this is generous; it is a backstop, not a budget.
|
||||
runTimeout = 4 * time.Minute
|
||||
// Successive all-error steps, and identical repeated calls, that end a run.
|
||||
maxConsecutiveToolErrors = 4
|
||||
maxSameCallRepeats = 3
|
||||
)
|
||||
|
||||
// Runner drives a model over pansy's toolbox. One per process; Run is safe to
|
||||
// call concurrently.
|
||||
type Runner struct {
|
||||
svc *service.Service
|
||||
model llm.Model
|
||||
}
|
||||
|
||||
// NewRunner resolves the configured model and returns a Runner, or an error if
|
||||
// the assistant can't be offered. Callers should treat an error as "no
|
||||
// assistant" rather than a startup failure — an instance with no key must still
|
||||
// serve the app.
|
||||
func NewRunner(svc *service.Service, cfg *config.Config) (*Runner, error) {
|
||||
if !cfg.Agent.Ready() {
|
||||
return nil, errors.New("agent: not configured")
|
||||
}
|
||||
|
||||
// A private registry, not the package-level default: pansy passes the key it
|
||||
// was configured with rather than depending on ambient environment, and
|
||||
// majordomo's own ollama-cloud preset reads OLLAMA_API_KEY while pansy (like
|
||||
// gadfly) is configured with OLLAMA_CLOUD_API_KEY. Registering the provider
|
||||
// explicitly makes that bridge visible instead of a mysterious empty token.
|
||||
reg := majordomo.New()
|
||||
reg.RegisterProvider(ollama.Cloud(ollama.WithToken(cfg.Agent.OllamaCloudAPIKey)))
|
||||
|
||||
// The spec goes to Parse VERBATIM. The grammar — including comma-separated
|
||||
// failover chains — is majordomo's, and re-implementing any of it here would
|
||||
// only mean two places to update when it grows.
|
||||
model, err := reg.Parse(cfg.Agent.Model)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agent: resolve model %q: %w", cfg.Agent.Model, err)
|
||||
}
|
||||
return &Runner{svc: svc, model: model}, nil
|
||||
}
|
||||
|
||||
// Turn is the outcome of one exchange.
|
||||
type Turn struct {
|
||||
// Reply is what to show the user.
|
||||
Reply string `json:"reply"`
|
||||
// ChangeSetID is the change set this turn produced, if it changed anything —
|
||||
// the handle the UI needs to offer Undo.
|
||||
ChangeSetID *int64 `json:"changeSetId,omitempty"`
|
||||
// Steps is how many model round trips it took.
|
||||
Steps int `json:"steps"`
|
||||
// Truncated is set when the run hit its step cap rather than finishing.
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
}
|
||||
|
||||
// Run executes one turn against a garden, as actorID.
|
||||
//
|
||||
// The whole turn runs inside ONE change set, so everything the model did undoes
|
||||
// together. That is what makes acting without a confirmation prompt defensible.
|
||||
// The scope is opened even for a turn that turns out to be a question — a change
|
||||
// set with no revisions is never written, so asking costs nothing.
|
||||
func (r *Runner) Run(ctx context.Context, actorID, gardenID int64, message string, history []llm.Message, onStep func(agent.Step)) (*Turn, error) {
|
||||
message = strings.TrimSpace(message)
|
||||
if message == "" {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, runTimeout)
|
||||
defer cancel()
|
||||
|
||||
garden, err := r.svc.GetGarden(ctx, actorID, gardenID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// An id for this run, stamped on the change set so a row in the history list
|
||||
// can be matched to the log lines that produced it. Without it, "the agent
|
||||
// did something odd on Tuesday" has no thread back to what it was thinking.
|
||||
runID := newRunID()
|
||||
slog.Info("agent: run start", "run", runID, "garden", gardenID, "actor", actorID)
|
||||
|
||||
var (
|
||||
result *agent.Result
|
||||
runErr error
|
||||
truncErr bool
|
||||
)
|
||||
changeSet, err := r.svc.WithChangeSet(ctx, actorID, gardenID, service.ChangeSetOptions{
|
||||
Source: domain.SourceAgent,
|
||||
Summary: turnSummary(message),
|
||||
AgentRunID: &runID,
|
||||
}, func(ctx context.Context) error {
|
||||
box := NewToolbox(r.svc, actorID)
|
||||
a := agent.New(r.model, systemPrompt(garden),
|
||||
agent.WithMaxSteps(maxSteps),
|
||||
agent.WithToolErrorLimits(maxConsecutiveToolErrors, maxSameCallRepeats),
|
||||
)
|
||||
a.AddToolbox(box)
|
||||
|
||||
opts := []agent.RunOption{agent.WithHistory(history)}
|
||||
if onStep != nil {
|
||||
opts = append(opts, agent.OnStep(onStep))
|
||||
}
|
||||
result, runErr = a.Run(ctx, message, opts...)
|
||||
// A run that ran out of steps still DID things, and those things must be
|
||||
// recorded and undoable. So the loop-guard errors don't fail the scope —
|
||||
// they're reported to the user instead.
|
||||
if runErr != nil && isLoopLimit(runErr) {
|
||||
truncErr = true
|
||||
return nil
|
||||
}
|
||||
return runErr
|
||||
})
|
||||
if err != nil {
|
||||
// WithChangeSet records whatever committed before failing, so the partial
|
||||
// work is undoable even though the turn errored.
|
||||
return nil, err
|
||||
}
|
||||
|
||||
turn := &Turn{Truncated: truncErr}
|
||||
if changeSet != nil {
|
||||
turn.ChangeSetID = &changeSet.ID
|
||||
}
|
||||
if result != nil {
|
||||
turn.Reply = result.Output
|
||||
turn.Steps = len(result.Steps)
|
||||
}
|
||||
if turn.Reply == "" {
|
||||
turn.Reply = fallbackReply(turn)
|
||||
}
|
||||
return turn, nil
|
||||
}
|
||||
|
||||
// isLoopLimit reports whether an error is one of majordomo's loop guards firing
|
||||
// rather than a genuine failure. Those runs have a partial result worth keeping.
|
||||
func isLoopLimit(err error) bool {
|
||||
return errors.Is(err, agent.ErrMaxSteps) || errors.Is(err, agent.ErrToolLoop)
|
||||
}
|
||||
|
||||
// fallbackReply covers a run that finished with no text — a model that made its
|
||||
// last tool call and then stopped. Silence reads as a failure, so say what
|
||||
// happened.
|
||||
func fallbackReply(t *Turn) string {
|
||||
switch {
|
||||
case t.Truncated:
|
||||
return "I stopped partway through — that turned into more steps than I should take in one go. " +
|
||||
"Have a look at what changed, and tell me what to do next."
|
||||
case t.ChangeSetID != nil:
|
||||
return "Done — have a look at the canvas."
|
||||
default:
|
||||
return "I didn't change anything."
|
||||
}
|
||||
}
|
||||
|
||||
// newRunID returns a short random identifier for one run.
|
||||
func newRunID() string {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
// The id is for correlating logs, not for security. A clock-based
|
||||
// fallback is worse than random and better than an empty string.
|
||||
return fmt.Sprintf("t%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// turnSummary is what the history list shows for this turn. The user's own words
|
||||
// are the most useful label available, trimmed to fit a list row.
|
||||
//
|
||||
// Trimmed by RUNES, not bytes: slicing a byte offset would cut a multibyte
|
||||
// character in half and store invalid UTF-8 in the summary — which is not a
|
||||
// hypothetical for text people type.
|
||||
func turnSummary(message string) string {
|
||||
const max = 120
|
||||
s := strings.Join(strings.Fields(message), " ")
|
||||
runes := []rune(s)
|
||||
if len(runes) > max {
|
||||
s = strings.TrimSpace(string(runes[:max])) + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// systemPrompt gives the model the conventions it cannot infer.
|
||||
//
|
||||
// The compass convention in particular is not guessable: -y is north because
|
||||
// screen y grows downward, and a model that assumes otherwise plants the south
|
||||
// half when asked for the north one.
|
||||
func systemPrompt(g *domain.Garden) string {
|
||||
units := "metric — all measurements are centimeters"
|
||||
if g.UnitPref == domain.UnitImperial {
|
||||
units = "imperial for display, but every measurement you send or receive is in CENTIMETERS"
|
||||
}
|
||||
return fmt.Sprintf(`You are pansy's garden assistant. You help plan and edit a real garden by calling tools.
|
||||
|
||||
The garden you are working on is %q (id %d), %.0f x %.0f cm. The user's units are %s.
|
||||
|
||||
Conventions you cannot guess and must not assume:
|
||||
- Positions in a garden are centimeters from its top-left corner: x grows east, y grows SOUTH.
|
||||
- Inside an object (a bed), positions are relative to that object's CENTER, and -y is NORTH.
|
||||
So the north half of a bed is negative y. Getting this backwards plants the wrong end.
|
||||
- Objects and plantings are version-guarded. Use the version from describe_garden when editing.
|
||||
|
||||
How to work:
|
||||
- Start from describe_garden to see what is actually there. Do not guess ids.
|
||||
- Use find_plant to turn a plant name into an id. If it returns several candidates,
|
||||
pick the one that matches what the user said, or ask them which they meant.
|
||||
- To replant a bed with something else: clear_object, then fill_region with region "all".
|
||||
- When a tool refuses (for example, the user only has view access to this garden),
|
||||
explain what happened in plain words. Do not retry it.
|
||||
|
||||
When you are done, say briefly what you changed — the user is watching the canvas
|
||||
and wants to know what to look at. If you changed nothing, say that too.`,
|
||||
g.Name, g.ID, g.WidthCM, g.HeightCM, units)
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||
)
|
||||
|
||||
// scriptedRunner wires a Runner to a fake provider so the whole loop — tools,
|
||||
// change-set scoping, loop guards — is exercised with no live model.
|
||||
func scriptedRunner(t *testing.T, svc *service.Service, steps ...fake.Step) *Runner {
|
||||
t.Helper()
|
||||
p := fake.New("fake")
|
||||
for _, s := range steps {
|
||||
p.Enqueue("m", s)
|
||||
}
|
||||
model, err := p.Model("m")
|
||||
if err != nil {
|
||||
t.Fatalf("fake model: %v", err)
|
||||
}
|
||||
return &Runner{svc: svc, model: model}
|
||||
}
|
||||
|
||||
// toolCall scripts one model turn that calls a tool.
|
||||
func toolCall(name string, args any) fake.Step {
|
||||
raw, _ := json.Marshal(args)
|
||||
return fake.ReplyWith(llm.Response{
|
||||
FinishReason: llm.FinishToolCalls,
|
||||
ToolCalls: []llm.ToolCall{{ID: "c1", Name: name, Arguments: raw}},
|
||||
})
|
||||
}
|
||||
|
||||
// TestTurnIsOneChangeSet is the acceptance criterion the whole "act freely"
|
||||
// posture rests on: a turn that clears a bed and replants it — one object edit
|
||||
// and many planting inserts — has to undo as ONE action, not thirteen.
|
||||
func TestTurnIsOneChangeSet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, owner := newAgentTestService(t)
|
||||
|
||||
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
|
||||
if err != nil {
|
||||
t.Fatalf("garden: %v", err)
|
||||
}
|
||||
garlic := mustPlant(t, svc, owner, "Garlic", 15, "🧄")
|
||||
cucumber := mustPlant(t, svc, owner, "Cucumber", 45, "🥒")
|
||||
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{
|
||||
Kind: domain.KindBed, Name: "Garlic bed", XCM: 1000, YCM: 1000, WidthCM: 400, HeightCM: 400,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("bed: %v", err)
|
||||
}
|
||||
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil); err != nil {
|
||||
t.Fatalf("seed garlic: %v", err)
|
||||
}
|
||||
|
||||
before, _, err := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("history: %v", err)
|
||||
}
|
||||
|
||||
r := scriptedRunner(t, svc,
|
||||
toolCall("clear_object", map[string]any{"objectId": bed.ID}),
|
||||
toolCall("fill_region", map[string]any{"objectId": bed.ID, "region": "all", "plantId": cucumber.ID}),
|
||||
fake.Reply("Cleared the garlic and replanted the bed with cucumbers."),
|
||||
)
|
||||
|
||||
turn, err := r.Run(ctx, owner, g.ID, "change the garlic bed to cucumbers this year", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if turn.ChangeSetID == nil {
|
||||
t.Fatal("a turn that changed things produced no change set")
|
||||
}
|
||||
if !strings.Contains(turn.Reply, "cucumbers") {
|
||||
t.Errorf("reply = %q", turn.Reply)
|
||||
}
|
||||
|
||||
// Exactly ONE new change set, whatever the model did inside the turn.
|
||||
after, _, err := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("history: %v", err)
|
||||
}
|
||||
if len(after)-len(before) != 1 {
|
||||
t.Fatalf("turn produced %d change sets, want exactly 1", len(after)-len(before))
|
||||
}
|
||||
cs := after[0]
|
||||
if cs.Source != domain.SourceAgent {
|
||||
t.Errorf("source = %q, want agent", cs.Source)
|
||||
}
|
||||
if cs.Summary != "change the garlic bed to cucumbers this year" {
|
||||
t.Errorf("summary = %q, want the user's own words", cs.Summary)
|
||||
}
|
||||
|
||||
// The bed really is cucumbers now.
|
||||
full, _ := svc.GardenFull(ctx, owner, g.ID, nil)
|
||||
if len(full.Plantings) == 0 {
|
||||
t.Fatal("bed ended up empty")
|
||||
}
|
||||
for _, p := range full.Plantings {
|
||||
if p.PlantID != cucumber.ID {
|
||||
t.Errorf("unexpected plant %d still in the bed", p.PlantID)
|
||||
}
|
||||
}
|
||||
|
||||
// And one undo puts it back.
|
||||
if _, conflicts, err := svc.RevertChangeSet(ctx, owner, *turn.ChangeSetID, domain.SourceUI); err != nil || len(conflicts) != 0 {
|
||||
t.Fatalf("undo: err=%v conflicts=%+v", err, conflicts)
|
||||
}
|
||||
full, _ = svc.GardenFull(ctx, owner, g.ID, nil)
|
||||
for _, p := range full.Plantings {
|
||||
if p.PlantID != garlic.ID {
|
||||
t.Errorf("after undo the bed holds plant %d, want the garlic back", p.PlantID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestViewerGetsAnExplainableRefusal — the ACL story only works if the model can
|
||||
// narrate the refusal, so a tool denial has to reach it as a tool RESULT it can
|
||||
// read, not as a 500 that ends the run.
|
||||
func TestViewerGetsAnExplainableRefusal(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, owner := newAgentTestService(t)
|
||||
viewer, err := svc.Register(ctx, service.RegisterInput{Email: "[email protected]", DisplayName: "V", Password: "password123"})
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
|
||||
if err != nil {
|
||||
t.Fatalf("garden: %v", err)
|
||||
}
|
||||
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{
|
||||
Kind: domain.KindBed, XCM: 1000, YCM: 1000, WidthCM: 400, HeightCM: 400,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("bed: %v", err)
|
||||
}
|
||||
if _, err := svc.AddShare(ctx, owner, g.ID, "[email protected]", domain.RoleViewer); err != nil {
|
||||
t.Fatalf("share: %v", err)
|
||||
}
|
||||
|
||||
// A viewer can't open a change set at all, so the turn is refused up front —
|
||||
// before any model call — and the API turns that into a plain explanation.
|
||||
r := scriptedRunner(t, svc, fake.Reply("unused"))
|
||||
_, err = r.Run(ctx, viewer.ID, g.ID, "plant garlic in that bed", nil, nil)
|
||||
if !errors.Is(err, domain.ErrForbidden) {
|
||||
t.Fatalf("viewer turn err = %v, want ErrForbidden", err)
|
||||
}
|
||||
|
||||
// And at the tool layer, a refusal comes back as a readable tool result
|
||||
// rather than killing the run.
|
||||
box := NewToolbox(svc, viewer.ID)
|
||||
raw, _ := json.Marshal(map[string]any{"objectId": bed.ID})
|
||||
res := box.Execute(ctx, llm.ToolCall{ID: "1", Name: "clear_object", Arguments: raw})
|
||||
if !res.IsError {
|
||||
t.Fatal("a viewer's clear_object succeeded")
|
||||
}
|
||||
if res.Content == "" {
|
||||
t.Error("the refusal carried no text for the model to explain")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunStopsAtTheStepCap — a model that gets wedged should stop on its own,
|
||||
// and what it managed to do must still be recorded and undoable.
|
||||
func TestRunStopsAtTheStepCap(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, owner := newAgentTestService(t)
|
||||
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
|
||||
if err != nil {
|
||||
t.Fatalf("garden: %v", err)
|
||||
}
|
||||
|
||||
// More describe_garden calls than the cap allows, forever.
|
||||
steps := make([]fake.Step, 0, maxSteps+4)
|
||||
for i := 0; i < maxSteps+4; i++ {
|
||||
steps = append(steps, toolCall("describe_garden", map[string]any{"gardenId": g.ID}))
|
||||
}
|
||||
r := scriptedRunner(t, svc, steps...)
|
||||
|
||||
turn, err := r.Run(ctx, owner, g.ID, "look at the garden", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("a capped run should end cleanly, got %v", err)
|
||||
}
|
||||
if !turn.Truncated {
|
||||
t.Error("turn.Truncated = false; the run hit the cap and should say so")
|
||||
}
|
||||
if turn.Reply == "" {
|
||||
t.Error("a capped run said nothing; silence reads as a hang")
|
||||
}
|
||||
// It only read, so there is nothing to undo — and no empty change set either.
|
||||
if turn.ChangeSetID != nil {
|
||||
t.Errorf("a read-only turn produced change set %d", *turn.ChangeSetID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadOnlyTurnWritesNoChangeSet — asking a question must not litter the
|
||||
// history with empty entries.
|
||||
func TestReadOnlyTurnWritesNoChangeSet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, owner := newAgentTestService(t)
|
||||
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
|
||||
if err != nil {
|
||||
t.Fatalf("garden: %v", err)
|
||||
}
|
||||
before, _, _ := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
|
||||
|
||||
r := scriptedRunner(t, svc,
|
||||
toolCall("describe_garden", map[string]any{"gardenId": g.ID}),
|
||||
fake.Reply("It's empty — nothing planted yet."),
|
||||
)
|
||||
turn, err := r.Run(ctx, owner, g.ID, "what's in the garden?", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if turn.ChangeSetID != nil {
|
||||
t.Errorf("a question produced change set %d", *turn.ChangeSetID)
|
||||
}
|
||||
after, _, _ := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
|
||||
if len(after) != len(before) {
|
||||
t.Errorf("history grew by %d for a read-only turn", len(after)-len(before))
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewRunnerNeedsConfiguration — an instance with no key must not get a
|
||||
// half-built runner; the caller treats the error as "no assistant" and carries on.
|
||||
func TestNewRunnerNeedsConfiguration(t *testing.T) {
|
||||
svc, _ := newAgentTestService(t)
|
||||
for _, cfg := range []*config.Config{
|
||||
{Agent: config.AgentConfig{Enabled: false, OllamaCloudAPIKey: "k", Model: "ollama-cloud/x"}},
|
||||
{Agent: config.AgentConfig{Enabled: true, OllamaCloudAPIKey: "", Model: "ollama-cloud/x"}},
|
||||
{Agent: config.AgentConfig{Enabled: true, OllamaCloudAPIKey: "k", Model: ""}},
|
||||
} {
|
||||
if _, err := NewRunner(svc, cfg); err == nil {
|
||||
t.Errorf("NewRunner accepted %+v", cfg.Agent)
|
||||
}
|
||||
}
|
||||
// A model spec naming a provider that doesn't exist is a configuration
|
||||
// error, not a panic at first use.
|
||||
if _, err := NewRunner(svc, &config.Config{Agent: config.AgentConfig{
|
||||
Enabled: true, OllamaCloudAPIKey: "k", Model: "nonesuch/model",
|
||||
}}); err == nil {
|
||||
t.Error("NewRunner accepted an unresolvable model spec")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnSummaryFitsAHistoryRow — the user's own words are the most useful
|
||||
// label for a change set, but a paragraph would wreck the list.
|
||||
func TestTurnSummaryFitsAHistoryRow(t *testing.T) {
|
||||
if got := turnSummary(" change the garlic bed\n to cucumbers "); got != "change the garlic bed to cucumbers" {
|
||||
t.Errorf("turnSummary = %q", got)
|
||||
}
|
||||
long := turnSummary(strings.Repeat("plant garlic ", 40))
|
||||
if len(long) > 130 || !strings.HasSuffix(long, "…") {
|
||||
t.Errorf("long summary = %q (%d chars)", long, len(long))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSystemPromptStatesTheCompassConvention — -y being north is not guessable,
|
||||
// and a model that assumes otherwise plants the wrong end of the bed.
|
||||
func TestSystemPromptStatesTheCompassConvention(t *testing.T) {
|
||||
p := systemPrompt(&domain.Garden{ID: 1, Name: "Plot", WidthCM: 500, HeightCM: 400, UnitPref: domain.UnitImperial})
|
||||
for _, want := range []string{"NORTH", "-y", "centimeters", "Plot", "version"} {
|
||||
if !strings.Contains(p, want) {
|
||||
t.Errorf("system prompt is missing %q:\n%s", want, p)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(p, fmt.Sprintf("%.0f", 500.0)) {
|
||||
t.Error("system prompt doesn't state the garden's size")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPartialWorkSurvivesATimeout is the finding that mattered most on this PR.
|
||||
//
|
||||
// The run context carries a timeout. When it fires, WithChangeSet's recovery
|
||||
// path has to record what already committed — and doing that with the SAME
|
||||
// dead context would fail, losing the history for changes that really happened.
|
||||
// The user-facing message says "anything I'd already changed is in History", so
|
||||
// this isn't just a gap, it's a promise the code has to keep.
|
||||
func TestPartialWorkSurvivesATimeout(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, owner := newAgentTestService(t)
|
||||
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
|
||||
if err != nil {
|
||||
t.Fatalf("garden: %v", err)
|
||||
}
|
||||
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{
|
||||
Kind: domain.KindBed, Name: "Bed", XCM: 1000, YCM: 1000, WidthCM: 400, HeightCM: 400,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("bed: %v", err)
|
||||
}
|
||||
before, _, _ := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
|
||||
|
||||
// A turn that renames the bed, then dies with the context already cancelled.
|
||||
cancelled, cancel := context.WithCancel(ctx)
|
||||
r := scriptedRunner(t, svc,
|
||||
toolCall("move_object", map[string]any{
|
||||
"objectId": bed.ID, "xCm": 600.0, "yCm": 600.0, "version": bed.Version,
|
||||
}),
|
||||
fake.Step{Err: context.DeadlineExceeded},
|
||||
)
|
||||
// Cancel once the first tool call has landed, so the failure path runs with a
|
||||
// dead context — exactly the timeout case.
|
||||
go func() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
_, err = r.Run(cancelled, owner, g.ID, "move the bed", nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected the turn to fail")
|
||||
}
|
||||
|
||||
// The move committed, so it must be in history and undoable.
|
||||
after, _, herr := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
|
||||
if herr != nil {
|
||||
t.Fatalf("history: %v", herr)
|
||||
}
|
||||
if len(after) != len(before)+1 {
|
||||
t.Fatalf("the failed turn recorded %d change sets, want 1 — its work is otherwise un-undoable",
|
||||
len(after)-len(before))
|
||||
}
|
||||
if !strings.Contains(after[0].Summary, "failed partway") {
|
||||
t.Errorf("summary = %q, want it marked as partial", after[0].Summary)
|
||||
}
|
||||
if _, conflicts, rerr := svc.RevertChangeSet(ctx, owner, after[0].ID, domain.SourceUI); rerr != nil || len(conflicts) != 0 {
|
||||
t.Fatalf("the partial turn should be undoable: err=%v conflicts=%+v", rerr, conflicts)
|
||||
}
|
||||
o, _ := svc.DescribeGarden(ctx, owner, g.ID)
|
||||
if len(o.Objects) > 0 && o.Objects[0].XCM != bed.XCM {
|
||||
t.Errorf("undo left the bed at %v, want %v", o.Objects[0].XCM, bed.XCM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnSummaryTrimsByRunes — slicing a byte offset would cut a multibyte
|
||||
// character in half and store invalid UTF-8 in the history summary.
|
||||
func TestTurnSummaryTrimsByRunes(t *testing.T) {
|
||||
// 200 multibyte runes: a byte slice at 120 would land mid-character.
|
||||
got := turnSummary(strings.Repeat("🌱", 200))
|
||||
if !utf8.ValidString(got) {
|
||||
t.Errorf("turnSummary produced invalid UTF-8: %q", got)
|
||||
}
|
||||
if !strings.HasSuffix(got, "…") {
|
||||
t.Errorf("long summary should be elided, got %q", got)
|
||||
}
|
||||
if n := utf8.RuneCountInString(got); n > 121 {
|
||||
t.Errorf("summary is %d runes, want it trimmed", n)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build majordomo
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build majordomo
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
mdagent "gitea.stevedudenhoeffer.com/steve/majordomo/agent"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agent"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
)
|
||||
|
||||
// The garden assistant's chat surface (#56).
|
||||
//
|
||||
// Streaming, because a turn that clears a bed and replants it makes a dozen tool
|
||||
// calls over tens of seconds. Without streaming that is a long silence followed
|
||||
// by everything at once, which reads as a hang — and the whole design rests on
|
||||
// watching the canvas change as it happens.
|
||||
|
||||
// chatRequest is the body of POST /agent/chat.
|
||||
type chatRequest struct {
|
||||
GardenID int64 `json:"gardenId" binding:"required"`
|
||||
Message string `json:"message" binding:"required"`
|
||||
}
|
||||
|
||||
// chatEvent is one server-sent event. Exactly one field is set.
|
||||
type chatEvent struct {
|
||||
// Step reports a completed model round trip: which tools it called.
|
||||
Step *stepEvent `json:"step,omitempty"`
|
||||
// Done carries the finished turn.
|
||||
Done *agent.Turn `json:"done,omitempty"`
|
||||
// Error is a turn that failed, in words meant for a person.
|
||||
Error string `json:"error,omitempty"`
|
||||
// Warning rides alongside Done: the turn worked, but something adjacent to it
|
||||
// didn't, and saying nothing would be the quieter lie.
|
||||
Warning string `json:"warning,omitempty"`
|
||||
}
|
||||
|
||||
type stepEvent struct {
|
||||
Index int `json:"index"`
|
||||
Tools []string `json:"tools"`
|
||||
}
|
||||
|
||||
func (h *handlers) agentChat(c *gin.Context) {
|
||||
var req chatRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a gardenId and a message are required")
|
||||
return
|
||||
}
|
||||
actor := mustActor(c)
|
||||
|
||||
history, err := h.svc.AgentHistory(c.Request.Context(), actor.ID, req.GardenID)
|
||||
if err != nil {
|
||||
writeServiceError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
send := openEventStream(c)
|
||||
|
||||
turn, err := h.agent.Run(c.Request.Context(), actor.ID, req.GardenID, req.Message,
|
||||
replayHistory(history),
|
||||
func(s mdagent.Step) {
|
||||
send(chatEvent{Step: &stepEvent{Index: s.Index, Tools: toolNames(s)}})
|
||||
})
|
||||
if err != nil {
|
||||
// The stream is already open, so an error is an event rather than a
|
||||
// status code — the client has committed to reading a stream by now.
|
||||
send(chatEvent{Error: chatErrorMessage(err)})
|
||||
return
|
||||
}
|
||||
|
||||
// The turn itself succeeded — the garden really did change — so Done goes out
|
||||
// regardless. But if the transcript couldn't be saved, say so: a clean "done"
|
||||
// followed by a conversation that has forgotten the exchange after a reload is
|
||||
// exactly the kind of quiet inconsistency that makes a tool feel unreliable.
|
||||
//
|
||||
// Detached from the request context, because the commonest reason this fails
|
||||
// is the client having gone away — and the exchange is worth keeping either
|
||||
// way, since the change set it produced certainly is.
|
||||
if _, err := h.svc.RecordAgentExchange(context.WithoutCancel(c.Request.Context()), actor.ID, req.GardenID,
|
||||
req.Message, turn.Reply, turn.ChangeSetID); err != nil {
|
||||
slog.Error("api: record agent exchange", "error", err, "garden", req.GardenID)
|
||||
send(chatEvent{Done: turn, Warning: "I couldn't save this exchange, so it won't be here after a reload. Anything I changed is still on the canvas, and in History."})
|
||||
return
|
||||
}
|
||||
send(chatEvent{Done: turn})
|
||||
}
|
||||
|
||||
// openEventStream puts the response into SSE mode and returns a sender.
|
||||
//
|
||||
// Headers go out before the first write and the stream is flushed immediately,
|
||||
// so a proxy holding the response until it looks complete can't reintroduce
|
||||
// exactly the silence streaming exists to remove.
|
||||
func openEventStream(c *gin.Context) func(chatEvent) {
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
c.Writer.Flush()
|
||||
|
||||
return func(ev chatEvent) {
|
||||
b, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
slog.Error("api: encode chat event", "error", err)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprintf(c.Writer, "data: %s\n\n", b)
|
||||
c.Writer.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// getAgentHistory returns the actor's thread for a garden.
|
||||
func (h *handlers) getAgentHistory(c *gin.Context) {
|
||||
gardenID, ok := parseIDParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
msgs, err := h.svc.AgentHistory(c.Request.Context(), mustActor(c).ID, gardenID)
|
||||
if err != nil {
|
||||
writeServiceError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"messages": msgs})
|
||||
}
|
||||
|
||||
// deleteAgentHistory is the "start over" escape hatch.
|
||||
func (h *handlers) deleteAgentHistory(c *gin.Context) {
|
||||
gardenID, ok := parseIDParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.svc.ClearAgentHistory(c.Request.Context(), mustActor(c).ID, gardenID); err != nil {
|
||||
writeServiceError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// replayHistory turns stored text into model messages.
|
||||
//
|
||||
// Only the text is replayed — no stored tool calls. Continuity needs what was
|
||||
// said and what came back; replaying a tool call would be replaying a decision
|
||||
// made against a garden that has since moved on.
|
||||
func replayHistory(msgs []domain.AgentMessage) []llm.Message {
|
||||
out := make([]llm.Message, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
if m.Role == domain.AgentRoleUser {
|
||||
out = append(out, llm.UserText(m.Body))
|
||||
} else {
|
||||
out = append(out, llm.AssistantText(m.Body))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toolNames(s mdagent.Step) []string {
|
||||
names := make([]string, 0, len(s.Results))
|
||||
for _, r := range s.Results {
|
||||
names = append(names, r.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// chatErrorMessage turns a failure into something worth reading. Permission
|
||||
// errors in particular get named: "the agent broke" and "you can only view this
|
||||
// garden" want very different reactions.
|
||||
func chatErrorMessage(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrForbidden):
|
||||
return "You can only view this garden, so I can't change anything in it."
|
||||
case errors.Is(err, domain.ErrNotFound):
|
||||
return "I can't find that garden."
|
||||
case errors.Is(err, domain.ErrInvalidInput):
|
||||
return "I didn't get a message to work from."
|
||||
case errors.Is(err, io.EOF), errors.Is(err, context.Canceled):
|
||||
return "The connection dropped partway through. Anything I'd already changed is on the canvas, and in History."
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return "That took too long and I stopped. Anything I'd already changed is on the canvas, and in History."
|
||||
default:
|
||||
slog.Error("api: agent run failed", "error", err)
|
||||
return "Something went wrong talking to the model. Anything I'd already changed is on the canvas, and in History."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAgentRoutesAbsentWithoutAKey — an instance with no API key must start,
|
||||
// serve the app, and simply not offer the assistant. The routes aren't
|
||||
// registered at all, so this is a 404 rather than a handler that apologizes:
|
||||
// the same shape as OIDC when unconfigured.
|
||||
func TestAgentRoutesAbsentWithoutAKey(t *testing.T) {
|
||||
r := authEngine(t, localCfg()) // localCfg has no agent configuration
|
||||
cookie := registerAndCookie(t, r, "[email protected]")
|
||||
gid := createGardenAPI(t, r, cookie, "G")
|
||||
|
||||
w := doJSON(t, r, http.MethodPost, "/api/v1/agent/chat",
|
||||
map[string]any{"gardenId": gid, "message": "plant garlic"}, cookie)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("chat without a key: status %d, want 404", w.Code)
|
||||
}
|
||||
|
||||
path := "/api/v1/gardens/" + strconv.FormatInt(gid, 10) + "/agent/history"
|
||||
if w := doJSON(t, r, http.MethodGet, path, nil, cookie); w.Code != http.StatusNotFound {
|
||||
t.Errorf("history without a key: status %d, want 404", w.Code)
|
||||
}
|
||||
|
||||
// And the rest of the app is entirely unaffected.
|
||||
if w := doJSON(t, r, http.MethodGet, fullPath(gid), nil, cookie); w.Code != http.StatusOK {
|
||||
t.Errorf("editor load: status %d, want 200 — an unconfigured agent must not break the app", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
sloggin "github.com/samber/slog-gin"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agent"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||
)
|
||||
@@ -22,6 +23,9 @@ type handlers struct {
|
||||
cfg *config.Config
|
||||
svc *service.Service
|
||||
oidc *oidcClient // nil unless OIDC is configured (see config.OIDCReady)
|
||||
// agent is nil unless the assistant is configured; the chat routes are only
|
||||
// registered when it isn't, so a handler never has to check.
|
||||
agent *agent.Runner
|
||||
}
|
||||
|
||||
// New builds the gin engine with the standard middleware stack and registers the
|
||||
@@ -118,6 +122,27 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
||||
plantings.PATCH("/:id", h.updatePlanting)
|
||||
plantings.DELETE("/:id", h.deletePlanting)
|
||||
|
||||
// The garden assistant, registered only when it can actually be offered —
|
||||
// the same shape as OIDC. An instance with no API key serves the app
|
||||
// normally and simply doesn't have these routes.
|
||||
if cfg.Agent.Ready() {
|
||||
runner, err := agent.NewRunner(svc, cfg)
|
||||
if err != nil {
|
||||
// Configured but unusable (an unresolvable model spec, say). Log it and
|
||||
// carry on without the assistant rather than refusing to start: a
|
||||
// garden planner that won't boot because of a chat feature is worse
|
||||
// than one without chat.
|
||||
slog.Error("api: garden assistant disabled", "error", err)
|
||||
} else {
|
||||
h.agent = runner
|
||||
agentGroup := v1.Group("/agent", h.requireAuth())
|
||||
agentGroup.POST("/chat", h.agentChat)
|
||||
gardens.GET("/:id/agent/history", h.getAgentHistory)
|
||||
gardens.DELETE("/:id/agent/history", h.deleteAgentHistory)
|
||||
slog.Info("api: garden assistant enabled", "model", cfg.Agent.Model)
|
||||
}
|
||||
}
|
||||
|
||||
// Undo. A change set is addressed by its own id; the service resolves the
|
||||
// owning garden for the permission check, same as objects and plantings.
|
||||
changeSets := v1.Group("/change-sets", h.requireAuth())
|
||||
|
||||
@@ -19,6 +19,9 @@ const (
|
||||
RegistrationClosed = "closed"
|
||||
)
|
||||
|
||||
// DefaultAgentModel is the assistant's model when PANSY_AGENT_MODEL is unset.
|
||||
const DefaultAgentModel = "ollama-cloud/glm-5.2:cloud"
|
||||
|
||||
// Config is the fully-resolved runtime configuration.
|
||||
type Config struct {
|
||||
// Port is the TCP port the HTTP server listens on (PANSY_PORT, default 8080).
|
||||
@@ -37,6 +40,8 @@ type Config struct {
|
||||
LocalAuth bool
|
||||
// OIDC holds the optional OpenID Connect provider settings.
|
||||
OIDC OIDCConfig
|
||||
// Agent holds the garden-assistant settings.
|
||||
Agent AgentConfig
|
||||
// TrustedProxies is the set of proxy CIDRs/IPs gin trusts for client IP
|
||||
// resolution (PANSY_TRUSTED_PROXIES, comma-separated). Empty trusts none.
|
||||
TrustedProxies []string
|
||||
@@ -51,6 +56,33 @@ type OIDCConfig struct {
|
||||
ButtonLabel string // PANSY_OIDC_BUTTON_LABEL — login-page button text
|
||||
}
|
||||
|
||||
// AgentConfig holds the garden assistant's settings.
|
||||
type AgentConfig struct {
|
||||
// Model is the majordomo model spec, passed VERBATIM to majordomo.Parse
|
||||
// (PANSY_AGENT_MODEL). The grammar is majordomo's, not pansy's — parsing or
|
||||
// validating it here would only mean two places to update when it grows. A
|
||||
// comma-separated spec is a failover chain, so
|
||||
// "ollama-cloud/glm-5.2:cloud,ollama-cloud/kimi-k2.6:cloud" gets a fallback
|
||||
// for free.
|
||||
Model string
|
||||
// OllamaCloudAPIKey authenticates against Ollama Cloud
|
||||
// (OLLAMA_CLOUD_API_KEY — the same secret name gadfly uses, which is why
|
||||
// it isn't majordomo's own OLLAMA_API_KEY; pansy passes it explicitly rather
|
||||
// than relying on ambient environment).
|
||||
OllamaCloudAPIKey string
|
||||
// Enabled turns the assistant on (PANSY_AGENT_ENABLED). Defaults to on when
|
||||
// a key is present, so an instance with no key starts cleanly and simply
|
||||
// doesn't offer the agent — the same shape as OIDC 404ing when unconfigured.
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// Ready reports whether the assistant can actually be offered. Both the route
|
||||
// registration and whatever advertises capabilities gate on this, so what's
|
||||
// advertised always matches what's live.
|
||||
func (a AgentConfig) Ready() bool {
|
||||
return a.Enabled && a.OllamaCloudAPIKey != "" && a.Model != ""
|
||||
}
|
||||
|
||||
// Enabled reports whether enough OIDC config is present to attempt discovery.
|
||||
func (o OIDCConfig) Enabled() bool {
|
||||
return o.Issuer != "" && o.ClientID != ""
|
||||
@@ -87,6 +119,16 @@ func Load() *Config {
|
||||
TrustedProxies: envList("PANSY_TRUSTED_PROXIES"),
|
||||
}
|
||||
|
||||
agentKey := envStr("OLLAMA_CLOUD_API_KEY", "")
|
||||
cfg.Agent = AgentConfig{
|
||||
Model: envStr("PANSY_AGENT_MODEL", DefaultAgentModel),
|
||||
OllamaCloudAPIKey: agentKey,
|
||||
// Default on when a key is present: having configured the key IS the
|
||||
// opt-in, and making people set a second flag to use what they just
|
||||
// configured is a papercut with no upside.
|
||||
Enabled: envBool("PANSY_AGENT_ENABLED", agentKey != ""),
|
||||
}
|
||||
|
||||
if cfg.Registration != RegistrationOpen && cfg.Registration != RegistrationClosed {
|
||||
slog.Warn("config: invalid PANSY_REGISTRATION, defaulting to open", "value", cfg.Registration)
|
||||
cfg.Registration = RegistrationOpen
|
||||
|
||||
@@ -191,6 +191,28 @@ type JournalEntry struct {
|
||||
AuthorName string `json:"authorName,omitempty"`
|
||||
}
|
||||
|
||||
// Roles a stored agent message can have.
|
||||
const (
|
||||
AgentRoleUser = "user"
|
||||
AgentRoleAssistant = "assistant"
|
||||
)
|
||||
|
||||
// AgentMessage is one side of a chat exchange with the garden assistant. Only
|
||||
// the text is kept, not the model's full transcript with its tool calls:
|
||||
// continuity needs what was said and what came back, and replaying a stored tool
|
||||
// call would be replaying a decision made against a garden that has since moved
|
||||
// on.
|
||||
type AgentMessage struct {
|
||||
ID int64 `json:"id"`
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
Role string `json:"role"`
|
||||
Body string `json:"body"`
|
||||
// ChangeSetID is what this turn changed, if anything — the handle the chat
|
||||
// panel needs to offer Undo on the message that caused it.
|
||||
ChangeSetID *int64 `json:"changeSetId,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// RevertConflict reports one entity a revert deliberately left alone, because
|
||||
// reverting it would have discarded a change made after the change set being
|
||||
// reverted. The rest of the change set still reverts.
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
)
|
||||
|
||||
// Chat persistence for the garden assistant (#56). The run loop itself lives in
|
||||
// internal/agent; this is the part that needs pansy's permission checks.
|
||||
|
||||
// maxRetainedTurns caps how much of a thread is kept in context. Retained turns
|
||||
// are a working memory, not an archive — a season of chat replayed into every
|
||||
// prompt would cost more than it informs.
|
||||
const maxRetainedTurns = 20
|
||||
|
||||
// AgentHistory returns the recent messages of the actor's own thread for a
|
||||
// garden they can edit, oldest first.
|
||||
//
|
||||
// Requires EDITOR, not viewer: the assistant acts, so being able to talk to it
|
||||
// about a garden is the same permission as being able to change it. A viewer
|
||||
// asking it to plant something would get a refusal from every tool anyway, and
|
||||
// offering the conversation would be offering something that cannot work.
|
||||
func (s *Service) AgentHistory(ctx context.Context, actorID, gardenID int64) ([]domain.AgentMessage, error) {
|
||||
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleEditor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conv, err := s.store.EnsureConversation(ctx, gardenID, actorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.store.ListAgentMessages(ctx, conv, maxRetainedTurns*2)
|
||||
}
|
||||
|
||||
// RecordAgentExchange stores one user message and the assistant's reply, and
|
||||
// returns the reply row. Called by the runtime after a turn completes.
|
||||
func (s *Service) RecordAgentExchange(ctx context.Context, actorID, gardenID int64, userMessage, reply string, changeSetID *int64) (*domain.AgentMessage, error) {
|
||||
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleEditor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conv, err := s.store.EnsureConversation(ctx, gardenID, actorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.store.AppendAgentMessage(ctx, &domain.AgentMessage{
|
||||
ConversationID: conv, Role: domain.AgentRoleUser, Body: userMessage,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.store.AppendAgentMessage(ctx, &domain.AgentMessage{
|
||||
ConversationID: conv, Role: domain.AgentRoleAssistant, Body: reply, ChangeSetID: changeSetID,
|
||||
})
|
||||
}
|
||||
|
||||
// ClearAgentHistory drops the actor's thread for a garden — the "start over"
|
||||
// escape hatch when a conversation has gone somewhere unhelpful.
|
||||
func (s *Service) ClearAgentHistory(ctx context.Context, actorID, gardenID int64) error {
|
||||
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleEditor); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.store.DeleteConversation(ctx, gardenID, actorID)
|
||||
}
|
||||
@@ -99,7 +99,8 @@ type ChangeSetOptions struct {
|
||||
Source string
|
||||
// Summary is the one-line description shown in the history list.
|
||||
Summary string
|
||||
// AgentRunID joins this change set back to the executus run that produced it.
|
||||
// AgentRunID joins this change set back to the agent run that produced it,
|
||||
// so a change in the history list can be correlated with the run's log lines.
|
||||
AgentRunID *string
|
||||
}
|
||||
|
||||
@@ -128,7 +129,11 @@ func (s *Service) WithChangeSet(ctx context.Context, actorID, gardenID int64, op
|
||||
// which is exactly the situation undo exists for. Record what happened,
|
||||
// mark the summary, and still report the failure.
|
||||
sc.summary = partialSummary(sc.summary)
|
||||
if _, cerr := s.commitScope(ctx, sc, nil); cerr != nil {
|
||||
// Detached from cancellation on purpose. The commonest reason fn failed is
|
||||
// that ctx was cancelled or timed out — and using that same dead context
|
||||
// to record what committed would fail too, losing the history for changes
|
||||
// that really happened. That is precisely the case this path exists for.
|
||||
if _, cerr := s.commitScope(context.WithoutCancel(ctx), sc, nil); cerr != nil {
|
||||
slog.Error("service: partial turn could not be recorded", "error", cerr, "garden", gardenID)
|
||||
}
|
||||
return nil, err
|
||||
@@ -306,8 +311,10 @@ func (s *Service) RevertChangeSet(ctx context.Context, actorID, changeSetID int6
|
||||
changes, conflict, err := s.applyInverse(ctx, r, applied)
|
||||
if err != nil {
|
||||
// Record what actually landed before surfacing the failure, so the
|
||||
// partial revert is visible and undoable rather than orphaned.
|
||||
if _, cerr := s.commitScope(ctx, sc, &target.ID); cerr != nil {
|
||||
// partial revert is visible and undoable rather than orphaned. Detached
|
||||
// from cancellation for the same reason as WithChangeSet's path: a
|
||||
// timed-out context can't be used to write the record of what it did.
|
||||
if _, cerr := s.commitScope(context.WithoutCancel(ctx), sc, &target.ID); cerr != nil {
|
||||
slog.Error("service: partial revert could not be recorded", "error", cerr, "changeSet", changeSetID)
|
||||
}
|
||||
return nil, nil, err
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
)
|
||||
|
||||
// agentMessageColumns lists agent_messages columns in scan order.
|
||||
const agentMessageColumns = `id, conversation_id, role, body, change_set_id, created_at`
|
||||
|
||||
// EnsureConversation returns the (garden, user) conversation id, creating it on
|
||||
// first use. One thread per person per garden: two people editing a shared
|
||||
// garden hold separate dialogues, and merging them would put words in someone's
|
||||
// mouth.
|
||||
func (d *DB) EnsureConversation(ctx context.Context, gardenID, userID int64) (int64, error) {
|
||||
var id int64
|
||||
err := d.sql.QueryRowContext(ctx,
|
||||
`SELECT id FROM agent_conversations WHERE garden_id = ? AND user_id = ?`,
|
||||
gardenID, userID).Scan(&id)
|
||||
if err == nil {
|
||||
return id, nil
|
||||
}
|
||||
if err := d.sql.QueryRowContext(ctx,
|
||||
`INSERT INTO agent_conversations (garden_id, user_id) VALUES (?, ?)
|
||||
ON CONFLICT (garden_id, user_id) DO UPDATE SET updated_at = updated_at
|
||||
RETURNING id`,
|
||||
gardenID, userID).Scan(&id); err != nil {
|
||||
return 0, fmt.Errorf("store: ensure conversation: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// AppendAgentMessage records one side of an exchange.
|
||||
func (d *DB) AppendAgentMessage(ctx context.Context, m *domain.AgentMessage) (*domain.AgentMessage, error) {
|
||||
var out domain.AgentMessage
|
||||
if err := d.sql.QueryRowContext(ctx,
|
||||
`INSERT INTO agent_messages (conversation_id, role, body, change_set_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
RETURNING `+agentMessageColumns,
|
||||
m.ConversationID, m.Role, m.Body, m.ChangeSetID,
|
||||
).Scan(&out.ID, &out.ConversationID, &out.Role, &out.Body, &out.ChangeSetID, &out.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: append agent message: %w", err)
|
||||
}
|
||||
// Touch the conversation so a "most recent thread" read is possible later.
|
||||
if _, err := d.sql.ExecContext(ctx,
|
||||
`UPDATE agent_conversations SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?`,
|
||||
m.ConversationID); err != nil {
|
||||
return nil, fmt.Errorf("store: touch conversation: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// ListAgentMessages returns the last `limit` messages of a conversation in
|
||||
// chronological order.
|
||||
//
|
||||
// Reading the TAIL rather than the head is the point: an old opening exchange is
|
||||
// the least useful context for "now do the same to the north bed", and an
|
||||
// uncapped read would grow without bound over a season.
|
||||
func (d *DB) ListAgentMessages(ctx context.Context, conversationID int64, limit int) ([]domain.AgentMessage, error) {
|
||||
rows, err := d.sql.QueryContext(ctx,
|
||||
`SELECT `+agentMessageColumns+` FROM (
|
||||
SELECT `+agentMessageColumns+` FROM agent_messages
|
||||
WHERE conversation_id = ? ORDER BY id DESC LIMIT ?
|
||||
) ORDER BY id`,
|
||||
conversationID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list agent messages: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
msgs := []domain.AgentMessage{}
|
||||
for rows.Next() {
|
||||
var m domain.AgentMessage
|
||||
if err := rows.Scan(&m.ID, &m.ConversationID, &m.Role, &m.Body, &m.ChangeSetID, &m.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan agent message: %w", err)
|
||||
}
|
||||
msgs = append(msgs, m)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: iterate agent messages: %w", err)
|
||||
}
|
||||
return msgs, nil
|
||||
}
|
||||
|
||||
// DeleteConversation clears a thread (the messages cascade).
|
||||
func (d *DB) DeleteConversation(ctx context.Context, gardenID, userID int64) error {
|
||||
if _, err := d.sql.ExecContext(ctx,
|
||||
`DELETE FROM agent_conversations WHERE garden_id = ? AND user_id = ?`,
|
||||
gardenID, userID); err != nil {
|
||||
return fmt.Errorf("store: delete conversation: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
-- Agent conversations (#56): multi-turn chat with the garden assistant.
|
||||
--
|
||||
-- "Now do the same to the north bed" needs the previous exchange, and history
|
||||
-- held only by the client would be lost on a refresh — which is exactly when
|
||||
-- someone reloads to see whether the agent's change actually landed.
|
||||
--
|
||||
-- One conversation per (user, garden). Two people editing a shared garden get
|
||||
-- separate threads: a conversation is a dialogue with one person, and merging
|
||||
-- them would put words in someone's mouth.
|
||||
CREATE TABLE agent_conversations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
garden_id INTEGER NOT NULL REFERENCES gardens (id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
UNIQUE (garden_id, user_id)
|
||||
);
|
||||
|
||||
-- Only the user/assistant TEXT of each turn is kept, not the full model
|
||||
-- transcript with its tool calls. Continuity needs what was said and what came
|
||||
-- back; replaying a stored tool call would be replaying a decision made against
|
||||
-- a garden that has since moved on. It also keeps the schema free of majordomo's
|
||||
-- message shape, which is a dependency's business, not the database's.
|
||||
CREATE TABLE agent_messages (
|
||||
conversation_id INTEGER NOT NULL REFERENCES agent_conversations (id) ON DELETE CASCADE,
|
||||
id INTEGER PRIMARY KEY,
|
||||
role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
|
||||
body TEXT NOT NULL,
|
||||
-- The change set this turn produced, so the panel can offer Undo on the
|
||||
-- message that caused it. SET NULL rather than CASCADE: losing the history
|
||||
-- entry must not delete what was said about it.
|
||||
change_set_id INTEGER REFERENCES change_sets (id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_agent_messages_conversation ON agent_messages (conversation_id, id);
|
||||
Reference in New Issue
Block a user