diff --git a/CLAUDE.md b/CLAUDE.md index 05ed44f..f522e8b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/DESIGN.md b/DESIGN.md index 3f50849..ebb46f4 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 diff --git a/README.md b/README.md index 02dd889..a4b959d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/go.mod b/go.mod index d28448b..3e46e30 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 42444ed..191e499 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/agent/doc.go b/internal/agent/doc.go index 339e45e..bd9b0e2 100644 --- a/internal/agent/doc.go +++ b/internal/agent/doc.go @@ -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 diff --git a/internal/agent/runtime.go b/internal/agent/runtime.go new file mode 100644 index 0000000..6ac91be --- /dev/null +++ b/internal/agent/runtime.go @@ -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) +} diff --git a/internal/agent/runtime_test.go b/internal/agent/runtime_test.go new file mode 100644 index 0000000..a331279 --- /dev/null +++ b/internal/agent/runtime_test.go @@ -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: "v@example.com", 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, "v@example.com", 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) + } +} diff --git a/internal/agent/tools.go b/internal/agent/tools.go index 5bc0718..9d59c7a 100644 --- a/internal/agent/tools.go +++ b/internal/agent/tools.go @@ -1,5 +1,3 @@ -//go:build majordomo - package agent import ( diff --git a/internal/agent/tools_test.go b/internal/agent/tools_test.go index f116377..d599540 100644 --- a/internal/agent/tools_test.go +++ b/internal/agent/tools_test.go @@ -1,5 +1,3 @@ -//go:build majordomo - package agent import ( diff --git a/internal/api/agent.go b/internal/api/agent.go new file mode 100644 index 0000000..2b19313 --- /dev/null +++ b/internal/api/agent.go @@ -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." + } +} diff --git a/internal/api/agent_test.go b/internal/api/agent_test.go new file mode 100644 index 0000000..216f8ca --- /dev/null +++ b/internal/api/agent_test.go @@ -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, "noagent@example.com") + 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) + } +} diff --git a/internal/api/api.go b/internal/api/api.go index a7e0339..021b5ea 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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()) diff --git a/internal/config/config.go b/internal/config/config.go index bdbc4f4..d3752dc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 diff --git a/internal/domain/domain.go b/internal/domain/domain.go index 0a6a9f3..37beac4 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -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. diff --git a/internal/service/agent.go b/internal/service/agent.go new file mode 100644 index 0000000..f3a1518 --- /dev/null +++ b/internal/service/agent.go @@ -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) +} diff --git a/internal/service/revisions.go b/internal/service/revisions.go index ee59555..fc373c9 100644 --- a/internal/service/revisions.go +++ b/internal/service/revisions.go @@ -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 diff --git a/internal/store/agent.go b/internal/store/agent.go new file mode 100644 index 0000000..c0252b6 --- /dev/null +++ b/internal/store/agent.go @@ -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 +} diff --git a/internal/store/migrations/0009_agent_chat.sql b/internal/store/migrations/0009_agent_chat.sql new file mode 100644 index 0000000..7f83401 --- /dev/null +++ b/internal/store/migrations/0009_agent_chat.sql @@ -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);