feat: introduce workflow job management and agent orchestration

- Added workflow job actor to manage job application workflows.
- Implemented agent catalog for various workflow agents.
- Created service agents for interview, roleplay, and Q-Score functionalities.
- Enhanced user authentication to automatically create users if they do not exist.
- Updated configuration to support new LLM provider and API keys.
- Introduced new routes for agent and workflow management.
- Refactored Docker management to improve Gitea admin user creation and token generation.
- Removed deprecated Anthropics SDK integration.
This commit is contained in:
2026-05-21 23:17:26 +05:30
parent ff0bf5e5f0
commit 2d471c61b4
20 changed files with 1169 additions and 367 deletions

View File

@@ -9,20 +9,30 @@ POSTGRES_PASSWORD=growqr
POSTGRES_DB=growqr POSTGRES_DB=growqr
# Clerk auth — get from dashboard.clerk.com → API Keys # Clerk auth — get from dashboard.clerk.com → API Keys
CLERK_SECRET_KEY=sk_test_REPLACE_ME CLERK_SECRET_KEY=clerk_key
CLERK_PUBLISHABLE_KEY=pk_test_REPLACE_ME CLERK_PUBLISHABLE_KEY=clerk_publishable_key
# Anthropic — get from console.anthropic.com → API Keys # OpenCode Zen LLM gateway — get from opencode.ai/auth
ANTHROPIC_API_KEY=sk-ant-REPLACE_ME OPENCODE_API_KEY=sk-REPLACE_ME
GROW_AGENT_MODEL=claude-opus-4-7 LLM_PROVIDER=opencode
SUB_AGENT_MODEL=claude-sonnet-4-6 LLM_BASE_URL=https://opencode.ai/zen/v1
LLM_MODEL=kimi-k2.6
GROW_AGENT_MODEL=kimi-k2.6
SUB_AGENT_MODEL=kimi-k2.6
MAX_AGENT_TOKENS=4096 MAX_AGENT_TOKENS=4096
# Shared secret for actor → backend service calls (rotate in prod) # Shared secret for actor → backend service calls (rotate in prod)
SERVICE_TOKEN=dev-service-token-REPLACE_ME SERVICE_TOKEN=dev-service-token-REPLACE_ME
A2A_ALLOWED_KEY=dev-a2a-key
# Rivet Kit engine (self-hosted in docker-compose) # Rivet Kit engine (self-hosted in docker-compose)
RIVET_ENDPOINT=http://localhost:6420 RIVET_ENDPOINT=http://localhost:6420
RIVET_CLIENT_ENDPOINT=http://127.0.0.1:4000/api/rivet
# Product microservice sub-agent URLs
INTERVIEW_SERVICE_URL=http://localhost:8007
ROLEPLAY_SERVICE_URL=http://localhost:8008
QSCORE_SERVICE_URL=http://localhost:8000
# Per-user container images # Per-user container images
GITEA_IMAGE=gitea/gitea:1.22 GITEA_IMAGE=gitea/gitea:1.22

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
node_modules node_modules
dist dist
.data
.env .env
.env.local .env.local
*.log *.log

View File

@@ -6,7 +6,7 @@ A multi-agent platform where every user gets a private Grow Agent (Rivet Kit act
- **Auth**: Clerk (frontend + backend JWT verification). - **Auth**: Clerk (frontend + backend JWT verification).
- **DB**: Postgres + Drizzle (users, actor registry, container mappings, repos, OpenCode sessions, events). - **DB**: Postgres + Drizzle (users, actor registry, container mappings, repos, OpenCode sessions, events).
- **Actors**: Rivet Kit — `growAgent` per user (master) and `subAgent` (worker), with a real Anthropic Claude tool-use loop. - **Actors**: Rivet Kit — `growAgent` per user (master) and `subAgent` (worker), with a real OpenCode Zen / OpenAI-compatible tool-use loop.
- **Per-user containers**: Gitea (memory repo) + OpenCode (workflow execution), spawned via `dockerode`, with admin user + access token bootstrap, ports allocated from a managed pool, lifecycle reconciled on backend boot. - **Per-user containers**: Gitea (memory repo) + OpenCode (workflow execution), spawned via `dockerode`, with admin user + access token bootstrap, ports allocated from a managed pool, lifecycle reconciled on backend boot.
- **Frontend**: Next.js 16 with `@clerk/nextjs` for auth and `rivetkit/client` for direct actor connections + event streaming. - **Frontend**: Next.js 16 with `@clerk/nextjs` for auth and `rivetkit/client` for direct actor connections + event streaming.
- **Tool surface available to the Grow Agent**: `spawn_sub_agent`, `commit_memory`, `read_memory`, `list_memory`. - **Tool surface available to the Grow Agent**: `spawn_sub_agent`, `commit_memory`, `read_memory`, `list_memory`.
@@ -16,7 +16,7 @@ A multi-agent platform where every user gets a private Grow Agent (Rivet Kit act
You need three external accounts before running: You need three external accounts before running:
1. **Clerk** — create an app at https://dashboard.clerk.com → copy the publishable + secret keys. 1. **Clerk** — create an app at https://dashboard.clerk.com → copy the publishable + secret keys.
2. **Anthropic** — create an API key at https://console.anthropic.com. 2. **OpenCode Zen** — create an API key at https://opencode.ai/auth.
3. **Docker** — Docker Desktop (or any daemon `dockerode` can reach via `/var/run/docker.sock`). 3. **Docker** — Docker Desktop (or any daemon `dockerode` can reach via `/var/run/docker.sock`).
Then: Then:
@@ -24,7 +24,7 @@ Then:
```bash ```bash
# Backend env # Backend env
cp .env.example .env cp .env.example .env
# fill in CLERK_SECRET_KEY, CLERK_PUBLISHABLE_KEY, ANTHROPIC_API_KEY # fill in CLERK_SECRET_KEY, CLERK_PUBLISHABLE_KEY, OPENCODE_API_KEY
# Frontend env # Frontend env
cd growqr-frontend cd growqr-frontend
@@ -73,7 +73,7 @@ Next.js (3000) ──fetch──▶ /users/bootstrap, /users/me (Hono on 40
Grow Agent actor (one per user) Grow Agent actor (one per user)
├─ Anthropic (Opus 4.7 + tool use) ├─ OpenCode Zen (Kimi K2.6 + tool use)
├─ commit_memory ────▶ Gitea container (per user) ├─ commit_memory ────▶ Gitea container (per user)
└─ spawn_sub_agent ────▶ OpenCode container (per user) └─ spawn_sub_agent ────▶ OpenCode container (per user)
(multiplexed sessions) (multiplexed sessions)

View File

@@ -54,12 +54,20 @@ services:
RIVET_ENDPOINT: http://rivet-engine:6420 RIVET_ENDPOINT: http://rivet-engine:6420
CLERK_SECRET_KEY: ${CLERK_SECRET_KEY} CLERK_SECRET_KEY: ${CLERK_SECRET_KEY}
CLERK_PUBLISHABLE_KEY: ${CLERK_PUBLISHABLE_KEY} CLERK_PUBLISHABLE_KEY: ${CLERK_PUBLISHABLE_KEY}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY} OPENCODE_API_KEY: ${OPENCODE_API_KEY}
GROW_AGENT_MODEL: ${GROW_AGENT_MODEL:-claude-opus-4-7} LLM_PROVIDER: ${LLM_PROVIDER:-opencode}
SUB_AGENT_MODEL: ${SUB_AGENT_MODEL:-claude-sonnet-4-6} LLM_BASE_URL: ${LLM_BASE_URL:-https://opencode.ai/zen/v1}
LLM_MODEL: ${LLM_MODEL:-kimi-k2.6}
GROW_AGENT_MODEL: ${GROW_AGENT_MODEL:-kimi-k2.6}
SUB_AGENT_MODEL: ${SUB_AGENT_MODEL:-kimi-k2.6}
SERVICE_TOKEN: ${SERVICE_TOKEN:-dev-service-token} SERVICE_TOKEN: ${SERVICE_TOKEN:-dev-service-token}
A2A_ALLOWED_KEY: ${A2A_ALLOWED_KEY:-dev-a2a-key}
RIVET_CLIENT_ENDPOINT: ${RIVET_CLIENT_ENDPOINT:-http://127.0.0.1:4000/api/rivet}
GITEA_IMAGE: ${GITEA_IMAGE:-gitea/gitea:1.22} GITEA_IMAGE: ${GITEA_IMAGE:-gitea/gitea:1.22}
OPENCODE_IMAGE: ${OPENCODE_IMAGE:-ghcr.io/sst/opencode:latest} OPENCODE_IMAGE: ${OPENCODE_IMAGE:-ghcr.io/anomalyco/opencode:latest}
INTERVIEW_SERVICE_URL: ${INTERVIEW_SERVICE_URL:-http://host.docker.internal:8007}
ROLEPLAY_SERVICE_URL: ${ROLEPLAY_SERVICE_URL:-http://host.docker.internal:8008}
QSCORE_SERVICE_URL: ${QSCORE_SERVICE_URL:-http://host.docker.internal:8000}
USER_CONTAINER_HOST: ${USER_CONTAINER_HOST:-host.docker.internal} USER_CONTAINER_HOST: ${USER_CONTAINER_HOST:-host.docker.internal}
USER_DATA_ROOT: /data/users USER_DATA_ROOT: /data/users
USER_PORT_RANGE_START: 20000 USER_PORT_RANGE_START: 20000

View File

@@ -366,7 +366,7 @@
<span class="swatch" style="background: var(--c-memory-bg)"></span> <span class="swatch" style="background: var(--c-memory-bg)"></span>
<div> <div>
<h3>Memory API</h3> <h3>Memory API</h3>
<p style="color: var(--muted)">Three-layer memory surface exposed to Claude as tools (<code>commit_memory</code>, <code>read_memory</code>, <code>list_memory</code>). L1 in-actor state, L2 session in Postgres, L3 long-term in the user's Gitea repo.</p> <p style="color: var(--muted)">Three-layer memory surface exposed to the configured OpenCode Zen model as tools (<code>commit_memory</code>, <code>read_memory</code>, <code>list_memory</code>). L1 in-actor state, L2 session in Postgres, L3 long-term in the user's Gitea repo.</p>
</div> </div>
</div> </div>
<div class="item" style="color: var(--c-git)"> <div class="item" style="color: var(--c-git)">

156
package-lock.json generated
View File

@@ -8,7 +8,6 @@
"name": "growqr-backend", "name": "growqr-backend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.96.0",
"@clerk/backend": "^1.21.0", "@clerk/backend": "^1.21.0",
"@hono/node-server": "^1.13.7", "@hono/node-server": "^1.13.7",
"dockerode": "^4.0.7", "dockerode": "^4.0.7",
@@ -38,36 +37,6 @@
"zod": "^3.25.0 || ^4.0.0" "zod": "^3.25.0 || ^4.0.0"
} }
}, },
"node_modules/@anthropic-ai/sdk": {
"version": "0.96.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.96.0.tgz",
"integrity": "sha512-KlCsODtTyb17bLUVCSDC2HtSvAbJf60sEiPEax9dInF+aDF92vS4TZJ5XD7YCQXNb1/5icYaw8Y7wMjPlIV9Zg==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1",
"standardwebhooks": "^1.0.0"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@balena/dockerignore": { "node_modules/@balena/dockerignore": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz", "resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz",
@@ -244,7 +213,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -261,7 +229,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -278,7 +245,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -295,7 +261,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -312,7 +277,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -329,7 +293,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -346,7 +309,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -363,7 +325,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -380,7 +341,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -397,7 +357,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -414,7 +373,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -431,7 +389,6 @@
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -448,7 +405,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -465,7 +421,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -482,7 +437,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -499,7 +453,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -516,7 +469,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -533,7 +485,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -550,7 +501,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -567,7 +517,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -584,7 +533,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -601,7 +549,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -668,7 +615,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -685,7 +631,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -702,7 +647,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -719,7 +663,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -736,7 +679,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -753,7 +695,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -770,7 +711,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -787,7 +727,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -804,7 +743,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -821,7 +759,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -838,7 +775,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -855,7 +791,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -872,7 +807,6 @@
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -889,7 +823,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -906,7 +839,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -923,7 +855,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -940,7 +871,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -957,7 +887,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -974,7 +903,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -991,7 +919,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1008,7 +935,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1025,7 +951,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1042,7 +967,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1059,7 +983,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1076,7 +999,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1093,7 +1015,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1157,7 +1078,6 @@
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18.14.1" "node": ">=18.14.1"
}, },
@@ -2096,12 +2016,6 @@
"linux" "linux"
] ]
}, },
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": { "node_modules/@standard-schema/spec": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
@@ -2305,7 +2219,6 @@
"integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"bindings": "^1.5.0", "bindings": "^1.5.0",
"prebuild-install": "^7.1.1" "prebuild-install": "^7.1.1"
@@ -2960,7 +2873,6 @@
"resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.12.tgz", "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.12.tgz",
"integrity": "sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw==", "integrity": "sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@balena/dockerignore": "^1.0.2", "@balena/dockerignore": "^1.0.2",
"@grpc/grpc-js": "^1.11.1", "@grpc/grpc-js": "^1.11.1",
@@ -3014,7 +2926,6 @@
"integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@drizzle-team/brocli": "^0.10.2", "@drizzle-team/brocli": "^0.10.2",
"@esbuild-kit/esm-loader": "^2.5.5", "@esbuild-kit/esm-loader": "^2.5.5",
@@ -3032,7 +2943,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3049,7 +2959,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3066,7 +2975,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3083,7 +2991,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3100,7 +3007,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3117,7 +3023,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3134,7 +3039,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3151,7 +3055,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3168,7 +3071,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3185,7 +3087,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3202,7 +3103,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3219,7 +3119,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3236,7 +3135,6 @@
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3253,7 +3151,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3270,7 +3167,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3287,7 +3183,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3304,7 +3199,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3321,7 +3215,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3338,7 +3231,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3355,7 +3247,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3372,7 +3263,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3389,7 +3279,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3406,7 +3295,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3423,7 +3311,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3440,7 +3327,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3457,7 +3343,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3811,12 +3696,6 @@
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fdb-tuple": { "node_modules/fdb-tuple": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fdb-tuple/-/fdb-tuple-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fdb-tuple/-/fdb-tuple-1.0.0.tgz",
@@ -3870,7 +3749,6 @@
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -3937,7 +3815,6 @@
"resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz", "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz",
"integrity": "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==", "integrity": "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=16" "node": ">=16"
}, },
@@ -4091,7 +3968,6 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.19.tgz", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.19.tgz",
"integrity": "sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ==", "integrity": "sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=16.9.0" "node": ">=16.9.0"
} }
@@ -4314,19 +4190,6 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/locate-path": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -4888,7 +4751,6 @@
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz", "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz",
"integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==", "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==",
"license": "Unlicense", "license": "Unlicense",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -5380,7 +5242,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
@@ -5774,16 +5635,6 @@
"nan": "^2.23.0" "nan": "^2.23.0"
} }
}, },
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/std-env": { "node_modules/std-env": {
"version": "3.10.0", "version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
@@ -5972,12 +5823,6 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT"
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -6307,7 +6152,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View File

@@ -15,7 +15,6 @@
"compose:down": "docker compose down" "compose:down": "docker compose down"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.96.0",
"@clerk/backend": "^1.21.0", "@clerk/backend": "^1.21.0",
"@hono/node-server": "^1.13.7", "@hono/node-server": "^1.13.7",
"dockerode": "^4.0.7", "dockerode": "^4.0.7",

View File

@@ -1,12 +1,13 @@
import { actor } from "rivetkit"; import { actor } from "rivetkit";
import type Anthropic from "@anthropic-ai/sdk";
import { log } from "../log.js"; import { log } from "../log.js";
import { config } from "../config.js"; import { config } from "../config.js";
import { import {
anthropic, createChatCompletion,
GROW_AGENT_SYSTEM, GROW_AGENT_SYSTEM,
growAgentTools, growAgentTools,
} from "../lib/anthropic.js"; type LlmMessage,
type LlmToolCall,
} from "../lib/llm.js";
import { import {
provisionUserStack, provisionUserStack,
getUserStack, getUserStack,
@@ -18,9 +19,10 @@ import { db } from "../db/client.js";
import { actors as actorsTable, events as eventsTable } from "../db/schema.js"; import { actors as actorsTable, events as eventsTable } from "../db/schema.js";
type ChatTurn = { type ChatTurn = {
role: "user" | "assistant"; role: "user" | "assistant" | "tool";
// Anthropic content blocks; "user" turns may also be plain strings. content: string;
content: string | Anthropic.ContentBlockParam[]; toolCallId?: string;
toolCalls?: LlmToolCall[];
}; };
type GrowAgentState = { type GrowAgentState = {
@@ -73,7 +75,7 @@ export const growAgent = actor({
return stack; return stack;
}, },
// Main chat entry point. Runs the full agentic loop with Claude. // Main chat entry point. Runs the full agentic loop through the configured LLM.
receiveMessage: async (c, msg: { text: string }) => { receiveMessage: async (c, msg: { text: string }) => {
if (!c.state.userId) { if (!c.state.userId) {
throw new Error("Grow Agent not initialized"); throw new Error("Grow Agent not initialized");
@@ -126,7 +128,8 @@ export const growAgent = actor({
}, },
}); });
// The agentic loop. Keeps calling Claude with tools until stop_reason === "end_turn". // The agentic loop. Keeps calling the configured LLM with tools until the model
// returns a normal assistant turn.
async function runAgentLoop( async function runAgentLoop(
c: { c: {
state: GrowAgentState; state: GrowAgentState;
@@ -134,9 +137,9 @@ async function runAgentLoop(
}, },
userId: string, userId: string,
): Promise<string> { ): Promise<string> {
if (!config.anthropicApiKey) { if (!config.llmApiKey) {
const reply = const reply =
"ANTHROPIC_API_KEY is not configured on the backend set it to enable the Grow Agent."; "LLM_API_KEY or OPENCODE_API_KEY is not configured on the backend - set it to enable the Grow Agent.";
c.state.history.push({ role: "assistant", content: reply }); c.state.history.push({ role: "assistant", content: reply });
c.broadcast("message", { role: "agent", text: reply }); c.broadcast("message", { role: "agent", text: reply });
return reply; return reply;
@@ -148,78 +151,80 @@ async function runAgentLoop(
let assistantTextOut = ""; let assistantTextOut = "";
for (let i = 0; i < MAX_ITERATIONS; i++) { for (let i = 0; i < MAX_ITERATIONS; i++) {
const response = await anthropic.messages.create({ const response = await createChatCompletion({
model: config.growAgentModel, model: config.growAgentModel,
max_tokens: config.maxAgentTokens, maxTokens: config.maxAgentTokens,
system: [
{
type: "text",
text: GROW_AGENT_SYSTEM,
cache_control: { type: "ephemeral" },
},
],
tools: growAgentTools, tools: growAgentTools,
thinking: { type: "adaptive" },
messages: messagesForApi(c.state.history), messages: messagesForApi(c.state.history),
}); });
// Capture assistant text for streaming-style broadcast. // Capture assistant text for streaming-style broadcast.
for (const block of response.content) { if (response.content) {
if (block.type === "text" && block.text) { assistantTextOut += (assistantTextOut ? "\n\n" : "") + response.content;
assistantTextOut += (assistantTextOut ? "\n\n" : "") + block.text; c.broadcast("message", { role: "agent", text: response.content });
c.broadcast("message", { role: "agent", text: block.text });
}
} }
// Persist the full assistant turn (so subsequent loops keep tool_use blocks). // Persist the assistant turn, including tool calls for the next tool result turn.
c.state.history.push({ c.state.history.push({
role: "assistant", role: "assistant",
content: response.content as Anthropic.ContentBlockParam[], content: response.content,
toolCalls: response.toolCalls,
}); });
if (response.stop_reason !== "tool_use") { if (response.toolCalls.length === 0) {
break; break;
} }
const toolUses = response.content.filter( for (const call of response.toolCalls) {
(b): b is Anthropic.ToolUseBlock => b.type === "tool_use",
);
if (toolUses.length === 0) break;
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const call of toolUses) {
try { try {
const result = await dispatchTool(c, userId, call); const result = await dispatchTool(c, userId, call);
toolResults.push({ c.state.history.push({
type: "tool_result", role: "tool",
tool_use_id: call.id, toolCallId: call.id,
content: typeof result === "string" ? result : JSON.stringify(result), content: typeof result === "string" ? result : JSON.stringify(result),
}); });
} catch (err) { } catch (err) {
log.error({ err, tool: call.name }, "tool dispatch failed"); log.error({ err, tool: call.name }, "tool dispatch failed");
toolResults.push({ c.state.history.push({
type: "tool_result", role: "tool",
tool_use_id: call.id, toolCallId: call.id,
content: `Error: ${err instanceof Error ? err.message : String(err)}`, content: `Error: ${err instanceof Error ? err.message : String(err)}`,
is_error: true,
}); });
} }
} }
c.state.history.push({ role: "user", content: toolResults });
} }
c.broadcast("agent-thinking", { state: "idle" }); c.broadcast("agent-thinking", { state: "idle" });
return assistantTextOut || "(no response)"; return assistantTextOut || "(no response)";
} }
function messagesForApi( function messagesForApi(history: ChatTurn[]): LlmMessage[] {
history: ChatTurn[], const messages: LlmMessage[] = [
): Anthropic.MessageParam[] { { role: "system", content: GROW_AGENT_SYSTEM },
return history.map((t) => ({ ];
role: t.role, for (const turn of history) {
content: t.content, if (turn.role === "tool") {
})) as Anthropic.MessageParam[]; messages.push({
role: "tool",
content: turn.content,
tool_call_id: turn.toolCallId,
});
continue;
}
messages.push({
role: turn.role,
content: turn.content,
tool_calls: turn.toolCalls?.map((call) => ({
id: call.id,
type: "function",
function: {
name: call.name,
arguments: JSON.stringify(call.arguments),
},
})),
});
}
return messages;
} }
async function dispatchTool( async function dispatchTool(
@@ -228,9 +233,9 @@ async function dispatchTool(
state: GrowAgentState; state: GrowAgentState;
}, },
userId: string, userId: string,
call: Anthropic.ToolUseBlock, call: LlmToolCall,
): Promise<unknown> { ): Promise<unknown> {
const input = call.input as Record<string, unknown>; const input = call.arguments;
switch (call.name) { switch (call.name) {
case "spawn_sub_agent": { case "spawn_sub_agent": {
const type = String(input.type ?? "generic"); const type = String(input.type ?? "generic");

View File

@@ -1,11 +1,13 @@
import { setup } from "rivetkit"; import { setup } from "rivetkit";
import { growAgent } from "./grow-agent.js"; import { growAgent } from "./grow-agent.js";
import { subAgent } from "./sub-agent.js"; import { subAgent } from "./sub-agent.js";
import { workflowJob } from "./workflow-job.js";
export const registry = setup({ export const registry = setup({
use: { use: {
growAgent, growAgent,
subAgent, subAgent,
workflowJob,
}, },
}); });

292
src/actors/workflow-job.ts Normal file
View File

@@ -0,0 +1,292 @@
import { actor } from "rivetkit";
import {
agentCatalog,
getAgentProfile,
jobApplicationAgentIds,
type AgentProfile,
} from "../agents/catalog.js";
import { runServiceAgentProbe, type ServiceAgentResult } from "../services/service-agents.js";
type WorkflowStatus = "draft" | "running" | "paused" | "completed";
type AgentStatus = "idle" | "running" | "blocked" | "done";
type AgentScorecard = {
id: string;
question: string;
answer: string;
score: number;
notes?: string;
createdAt: string;
};
type WorkflowAgentState = {
id: string;
name: string;
role: string;
kind: AgentProfile["kind"];
service?: AgentProfile["service"];
status: AgentStatus;
summary: string;
lastResult?: ServiceAgentResult;
scorecards: AgentScorecard[];
};
type WorkflowEvent = {
id: string;
ts: string;
agentId: string;
agentName: string;
type: "workflow" | "agent" | "score";
message: string;
payload?: unknown;
};
type WorkflowJobState = {
workflowId: string;
userId: string;
type: "job-application";
status: WorkflowStatus;
goal: string;
agents: WorkflowAgentState[];
timeline: WorkflowEvent[];
createdAt: string;
updatedAt: string;
};
const now = () => new Date().toISOString();
const initialState: WorkflowJobState = {
workflowId: "",
userId: "",
type: "job-application",
status: "draft",
goal: "",
agents: [],
timeline: [],
createdAt: "",
updatedAt: "",
};
function eventId() {
return `evt_${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
function makeAgents(): WorkflowAgentState[] {
return jobApplicationAgentIds()
.map((id) => getAgentProfile(id))
.filter((agent): agent is AgentProfile => Boolean(agent))
.map((agent) => ({
id: agent.id,
name: agent.name,
role: agent.role,
kind: agent.kind,
service: agent.service,
status: "idle",
summary: agent.description,
scorecards: [],
}));
}
function appendEvent(
state: WorkflowJobState,
agent: Pick<WorkflowAgentState, "id" | "name">,
type: WorkflowEvent["type"],
message: string,
payload?: unknown,
) {
const ev: WorkflowEvent = {
id: eventId(),
ts: now(),
agentId: agent.id,
agentName: agent.name,
type,
message,
payload,
};
state.timeline.unshift(ev);
state.timeline = state.timeline.slice(0, 100);
state.updatedAt = ev.ts;
return ev;
}
function localAgentResult(agent: WorkflowAgentState, goal: string): ServiceAgentResult {
const goalText = goal || "job application workflow";
switch (agent.id) {
case "grow":
return {
status: "local",
summary: `Grow Agent initialized the ${goalText} workflow and assigned specialist agents.`,
};
case "resume":
return {
status: "local",
summary: `Resume Agent prepared a resume-improvement pass for ${goalText}.`,
};
case "job-search":
return {
status: "local",
summary: `Job Search Agent created the opportunity discovery lane for ${goalText}.`,
};
case "job-apply":
return {
status: "local",
summary: `Job Apply Agent prepared the application tracking lane for ${goalText}.`,
};
default:
return {
status: "local",
summary: `${agent.name} completed a local workflow step.`,
};
}
}
export const workflowJob = actor({
options: {
actionTimeout: 600_000,
noSleep: true,
},
state: initialState,
actions: {
init: async (
c,
input: {
userId: string;
goal?: string;
},
) => {
if (c.state.userId && c.state.userId !== input.userId) {
throw new Error("Workflow already belongs to another user");
}
if (!c.state.workflowId) {
const ts = now();
c.state.workflowId = `job-application:${input.userId}`;
c.state.userId = input.userId;
c.state.type = "job-application";
c.state.goal = input.goal ?? "Find and apply to high-fit jobs";
c.state.status = "draft";
c.state.agents = makeAgents();
c.state.createdAt = ts;
c.state.updatedAt = ts;
appendEvent(
c.state,
{ id: "grow", name: "Grow Agent" },
"workflow",
"Job application workflow created.",
{ catalog: agentCatalog },
);
} else if (input.goal) {
c.state.goal = input.goal;
c.state.updatedAt = now();
}
c.broadcast("workflow.updated", c.state);
return c.state;
},
start: async (c) => {
c.state.status = "running";
appendEvent(
c.state,
{ id: "grow", name: "Grow Agent" },
"workflow",
"Workflow started.",
);
c.broadcast("workflow.updated", c.state);
return c.state;
},
pause: async (c) => {
c.state.status = "paused";
appendEvent(
c.state,
{ id: "grow", name: "Grow Agent" },
"workflow",
"Workflow paused.",
);
c.broadcast("workflow.updated", c.state);
return c.state;
},
resume: async (c) => {
c.state.status = "running";
appendEvent(
c.state,
{ id: "grow", name: "Grow Agent" },
"workflow",
"Workflow resumed.",
);
c.broadcast("workflow.updated", c.state);
return c.state;
},
runAgent: async (c, input: { agentId: string }) => {
const agent = c.state.agents.find((item) => item.id === input.agentId);
if (!agent) throw new Error(`Unknown workflow agent: ${input.agentId}`);
agent.status = "running";
appendEvent(c.state, agent, "agent", `${agent.name} started.`);
c.broadcast("workflow.updated", c.state);
const profile = getAgentProfile(agent.id);
if (profile?.service != null) {
const userId = c.state.userId;
const goal = c.state.goal;
c.waitUntil(
(async () => {
const result = await runServiceAgentProbe(profile, {
userId,
goal,
});
agent.lastResult = result;
agent.status = result.status === "unavailable" ? "blocked" : "done";
appendEvent(c.state, agent, "agent", result.summary, result.detail);
c.broadcast("workflow.updated", c.state);
await c.saveState({ immediate: true });
})(),
);
return c.state;
}
const result = localAgentResult(agent, c.state.goal);
agent.lastResult = result;
agent.status = result.status === "unavailable" ? "blocked" : "done";
appendEvent(c.state, agent, "agent", result.summary, result.detail);
c.broadcast("workflow.updated", c.state);
return c.state;
},
recordQaScore: async (
c,
input: {
agentId: string;
question: string;
answer: string;
score: number;
notes?: string;
},
) => {
const agent = c.state.agents.find((item) => item.id === input.agentId);
if (!agent) throw new Error(`Unknown workflow agent: ${input.agentId}`);
const card: AgentScorecard = {
id: `score_${Date.now()}`,
question: input.question,
answer: input.answer,
score: Math.max(0, Math.min(100, Number(input.score))),
notes: input.notes,
createdAt: now(),
};
agent.scorecards.unshift(card);
appendEvent(
c.state,
agent,
"score",
`${agent.name} recorded Q&A score ${card.score}.`,
card,
);
c.broadcast("workflow.updated", c.state);
return c.state;
},
getStatus: async (c) => c.state,
},
});

94
src/agents/catalog.ts Normal file
View File

@@ -0,0 +1,94 @@
export type AgentKind =
| "master"
| "workflow"
| "microservice"
| "score";
export type AgentProfile = {
id: string;
name: string;
role: string;
kind: AgentKind;
description: string;
service?: "interview-service" | "roleplay-service" | "qscore-service";
};
export const agentCatalog = [
{
id: "grow",
name: "Grow Agent",
role: "Master Orchestrator",
kind: "master",
description:
"Owns user context, routes work to sub-agents, commits durable memory, and tracks workflow progress.",
},
{
id: "resume",
name: "Resume Agent",
role: "Resume Builder",
kind: "workflow",
description:
"Turns profile context, Q-Score gaps, and target roles into resume edits and application collateral.",
},
{
id: "job-search",
name: "Job Search Agent",
role: "Opportunity Scout",
kind: "workflow",
description:
"Finds relevant jobs, ranks opportunities, and prepares a shortlist for the application workflow.",
},
{
id: "job-apply",
name: "Job Apply Agent",
role: "Application Operator",
kind: "workflow",
description:
"Prepares tailored applications, tracks submissions, and records follow-up tasks.",
},
{
id: "sara",
name: "Sara",
role: "Interview Agent",
kind: "microservice",
service: "interview-service",
description:
"Runs interview practice through the interview-service microservice and owns interview Q&A feedback.",
},
{
id: "emily",
name: "Emily",
role: "Roleplay Agent",
kind: "microservice",
service: "roleplay-service",
description:
"Runs roleplay practice through the roleplay-service microservice and owns scenario feedback.",
},
{
id: "qscore",
name: "Quinn",
role: "Q-Score Agent",
kind: "score",
service: "qscore-service",
description:
"Computes and explains Q-Score changes, then displays Q&A and scores under the owning agent.",
},
] as const satisfies AgentProfile[];
export type AgentId = (typeof agentCatalog)[number]["id"];
export function getAgentProfile(id: string): AgentProfile | undefined {
return agentCatalog.find((agent) => agent.id === id);
}
export function jobApplicationAgentIds(): AgentId[] {
return [
"grow",
"resume",
"job-search",
"job-apply",
"sara",
"emily",
"qscore",
];
}

View File

@@ -34,7 +34,19 @@ export const requireUser = createMiddleware<AuthContext>(async (c, next) => {
c.req.header("x-growqr-user") c.req.header("x-growqr-user")
) { ) {
const userId = c.req.header("x-growqr-user")!; const userId = c.req.header("x-growqr-user")!;
const row = await db.query.users.findFirst({ where: eq(users.id, userId) }); let row = await db.query.users.findFirst({ where: eq(users.id, userId) });
if (!row) {
const inserted = await db
.insert(users)
.values({
id: userId,
email: `${userId}@service.local`,
displayName: userId,
})
.onConflictDoNothing()
.returning();
row = inserted[0];
}
if (!row) { if (!row) {
throw new HTTPException(401, { message: "service token references unknown user" }); throw new HTTPException(401, { message: "service token references unknown user" });
} }

View File

@@ -23,21 +23,46 @@ export const config = {
clerkPublishableKey: process.env.CLERK_PUBLISHABLE_KEY ?? "", clerkPublishableKey: process.env.CLERK_PUBLISHABLE_KEY ?? "",
// Optional: lock service-to-service calls (actor → backend). // Optional: lock service-to-service calls (actor → backend).
serviceToken: process.env.SERVICE_TOKEN ?? "", serviceToken: process.env.SERVICE_TOKEN ?? "",
a2aAllowedKey: process.env.A2A_ALLOWED_KEY ?? "dev-a2a-key",
// Anthropic for Grow Agent + sub-agent LLM calls. // LLM gateway for Grow Agent + sub-agent planning calls.
anthropicApiKey: process.env.ANTHROPIC_API_KEY ?? "", llmProvider: process.env.LLM_PROVIDER ?? "opencode",
llmApiKey:
process.env.LLM_API_KEY ??
process.env.OPENCODE_API_KEY ??
"",
llmBaseUrl:
process.env.LLM_BASE_URL ??
process.env.OPENCODE_BASE_URL ??
"https://opencode.ai/zen/v1",
opencodeApiKey: process.env.OPENCODE_API_KEY ?? "",
growAgentModel: growAgentModel:
process.env.GROW_AGENT_MODEL ?? "claude-opus-4-7", process.env.GROW_AGENT_MODEL ??
process.env.LLM_MODEL ??
"kimi-k2.6",
subAgentModel: subAgentModel:
process.env.SUB_AGENT_MODEL ?? "claude-sonnet-4-6", process.env.SUB_AGENT_MODEL ??
process.env.LLM_MODEL ??
"kimi-k2.6",
// Rivet Kit engine endpoint (self-hosted in docker-compose). // Rivet Kit engine endpoint (self-hosted in docker-compose).
rivetEndpoint: process.env.RIVET_ENDPOINT ?? "http://localhost:6420", rivetEndpoint: process.env.RIVET_ENDPOINT ?? "http://localhost:6420",
rivetClientEndpoint:
process.env.RIVET_CLIENT_ENDPOINT ??
`http://127.0.0.1:${Number(process.env.PORT ?? 4000)}/api/rivet`,
// Product microservices exposed as sub-agent surfaces.
interviewServiceUrl:
process.env.INTERVIEW_SERVICE_URL ?? "http://localhost:8007",
roleplayServiceUrl:
process.env.ROLEPLAY_SERVICE_URL ?? "http://localhost:8008",
qscoreServiceUrl:
process.env.QSCORE_SERVICE_URL ?? "http://localhost:8000",
// Per-user container images. // Per-user container images.
giteaImage: process.env.GITEA_IMAGE ?? "gitea/gitea:1.22", giteaImage: process.env.GITEA_IMAGE ?? "gitea/gitea:1.22",
opencodeImage: opencodeImage:
process.env.OPENCODE_IMAGE ?? "ghcr.io/sst/opencode:latest", process.env.OPENCODE_IMAGE ?? "ghcr.io/anomalyco/opencode:latest",
// Host that user containers expose ports on (the host running Docker). // Host that user containers expose ports on (the host running Docker).
userContainerHost: process.env.USER_CONTAINER_HOST ?? "127.0.0.1", userContainerHost: process.env.USER_CONTAINER_HOST ?? "127.0.0.1",
@@ -49,7 +74,7 @@ export const config = {
frontendOrigin: frontendOrigin:
process.env.FRONTEND_ORIGIN ?? "http://localhost:3000", process.env.FRONTEND_ORIGIN ?? "http://localhost:3000",
// Used by Anthropic SDK extended thinking / streaming budgets. // Used by LLM requests.
maxAgentTokens: Number(process.env.MAX_AGENT_TOKENS ?? 4096), maxAgentTokens: Number(process.env.MAX_AGENT_TOKENS ?? 4096),
required, // exported so other modules can fail fast on boot required, // exported so other modules can fail fast on boot

View File

@@ -133,42 +133,95 @@ async function startGiteaContainer(opts: {
return { id: container.id, name }; return { id: container.id, name };
} }
function shellQuote(value: string): string {
return `'${value.replace(/'/g, "'\\''")}'`;
}
async function execGiteaCli(containerId: string, args: string[]): Promise<string> {
const container = docker.getContainer(containerId);
const command = [
"gitea",
"--work-path",
"/data/gitea",
"--config",
"/data/gitea/conf/app.ini",
...args,
]
.map(shellQuote)
.join(" ");
const exec = await container.exec({
Cmd: ["su", "git", "-c", command],
AttachStdout: true,
AttachStderr: true,
WorkingDir: "/data/gitea",
});
const stream = await exec.start({ Detach: false, Tty: false });
const chunks: Buffer[] = [];
stream.on("data", (chunk: Buffer) => chunks.push(Buffer.from(chunk)));
await new Promise<void>((resolve) => {
stream.on("end", () => resolve());
stream.on("close", () => resolve());
});
const output = Buffer.concat(chunks).toString("utf8");
const info = await exec.inspect();
if (info.ExitCode && info.ExitCode !== 0) {
throw new Error(`gitea cli failed (${info.ExitCode}): ${output}`);
}
return output;
}
// Runs `gitea admin user create --admin ...` inside the container. // Runs `gitea admin user create --admin ...` inside the container.
// Idempotent: returns existing creds if the user already exists. // Idempotent: the CLI returns non-zero if the user already exists, which is fine.
async function ensureGiteaAdmin(opts: { async function ensureGiteaAdmin(opts: {
containerId: string; containerId: string;
username: string; username: string;
password: string; password: string;
email: string; email: string;
}): Promise<void> { }): Promise<void> {
const container = docker.getContainer(opts.containerId); try {
const exec = await container.exec({ await execGiteaCli(opts.containerId, [
Cmd: [ "admin",
"su", "user",
"git", "create",
"-c", "--admin",
`gitea admin user create --admin --username ${opts.username} --password '${opts.password.replace(/'/g, "'\\''")}' --email ${opts.email} --must-change-password=false`, "--username",
], opts.username,
AttachStdout: true, "--password",
AttachStderr: true, opts.password,
WorkingDir: "/var/lib/gitea", "--email",
}); opts.email,
const stream = await exec.start({ Detach: false, Tty: false }); "--must-change-password=false",
await new Promise<void>((resolve) => { ]);
stream.on("end", () => resolve()); } catch (err) {
stream.on("close", () => resolve());
stream.resume();
});
const info = await exec.inspect();
if (info.ExitCode && info.ExitCode !== 0) {
// Most common non-zero: "user already exists" — that's fine.
log.debug( log.debug(
{ exitCode: info.ExitCode }, { err },
"gitea admin user create returned non-zero (likely already exists)", "gitea admin user create returned non-zero (likely already exists)",
); );
} }
} }
async function generateGiteaToken(opts: {
containerId: string;
username: string;
scopes: string[];
}): Promise<string> {
const output = await execGiteaCli(opts.containerId, [
"admin",
"user",
"generate-access-token",
"--username",
opts.username,
"--token-name",
`growqr-backend-${Date.now()}`,
"--scopes",
opts.scopes.join(","),
"--raw",
]);
const token = output.match(/[a-f0-9]{40}/i)?.[0];
if (!token) throw new Error("gitea token generation returned an empty token");
return token;
}
async function startOpencodeContainer(opts: { async function startOpencodeContainer(opts: {
userId: string; userId: string;
httpPort: number; httpPort: number;
@@ -298,15 +351,11 @@ export async function provisionUserStack(userId: string): Promise<UserStack> {
email: adminEmail, email: adminEmail,
}); });
// Mint a token using basic auth. // Mint a token via Gitea's CLI so retries do not depend on a transient
const basicClient = new GiteaClient(giteaBase, { // bootstrap password from a previous provisioning attempt.
kind: "basic", const token = await generateGiteaToken({
containerId: gitea.id,
username: adminUsername, username: adminUsername,
password: adminPassword,
});
const token = await basicClient.ensureAccessToken({
username: adminUsername,
name: "growqr-backend",
scopes: ["write:repository", "write:user", "write:issue"], scopes: ["write:repository", "write:user", "write:issue"],
}); });

View File

@@ -9,6 +9,8 @@ import { actorRoutes } from "./routes/actors.js";
import { opencodeRoutes } from "./routes/opencode.js"; import { opencodeRoutes } from "./routes/opencode.js";
import { gitRoutes } from "./routes/git.js"; import { gitRoutes } from "./routes/git.js";
import { userRoutes } from "./routes/users.js"; import { userRoutes } from "./routes/users.js";
import { agentRoutes } from "./routes/agents.js";
import { workflowRoutes } from "./routes/workflows.js";
import { db } from "./db/client.js"; import { db } from "./db/client.js";
import { hydratePortAllocator, reconcileOnBoot } from "./docker/manager.js"; import { hydratePortAllocator, reconcileOnBoot } from "./docker/manager.js";
@@ -58,10 +60,17 @@ async function main() {
// PRD HTTP control plane (auth-gated). // PRD HTTP control plane (auth-gated).
app.route("/users", userRoutes()); app.route("/users", userRoutes());
app.route("/agents", agentRoutes());
app.route("/workflows", workflowRoutes());
app.route("/actors", actorRoutes()); app.route("/actors", actorRoutes());
app.route("/opencode", opencodeRoutes()); app.route("/opencode", opencodeRoutes());
app.route("/git", gitRoutes()); app.route("/git", gitRoutes());
if (process.env.RIVET_RUN_ENGINE === "1") {
delete process.env.RIVET_ENDPOINT;
}
registry.startRunner();
serve({ fetch: app.fetch, port: config.port }, (info) => { serve({ fetch: app.fetch, port: config.port }, (info) => {
log.info( log.info(
{ {

View File

@@ -1,104 +0,0 @@
import Anthropic from "@anthropic-ai/sdk";
import { config } from "../config.js";
export const anthropic = new Anthropic({
apiKey: config.anthropicApiKey,
});
export const GROW_AGENT_SYSTEM = `You are a Grow Agent — a user's master AI orchestrator on the GrowQR platform.
You own this user's long-running context, memory, and workspace. You coordinate specialized sub-agents (coding, repo, quest, product-flow, etc.), keep durable state in the user's Gitea memory repository, and execute workflows via the user's OpenCode sandbox.
Operating principles:
- Be concise and direct. The user sees your messages in a Slack-like chat.
- Maintain durable memory: commit important decisions, goals, and progress to the user's memory repo using \`commit_memory\`. Read existing context with \`read_memory\` before making suggestions that depend on history.
- For anything that requires code, shell, file edits, or generated artifacts, spawn a sub-agent via \`spawn_sub_agent\`. The sub-agent runs through the user's OpenCode container.
- Track active goals and quests. Surface progress proactively when the user returns.
- Prefer one small commit per meaningful state change over batching.
- Never invent tool names. Only use the tools provided.
`;
export type GrowAgentTool =
| "spawn_sub_agent"
| "commit_memory"
| "read_memory"
| "list_memory";
export const growAgentTools: Anthropic.Tool[] = [
{
name: "spawn_sub_agent",
description:
"Spawn a specialized sub-agent to run a bounded task through the user's OpenCode container. Use for anything that requires running code, editing files, or producing artifacts.",
input_schema: {
type: "object",
properties: {
type: {
type: "string",
description:
"Sub-agent type: 'coding', 'repo', 'migration', 'quest', 'product', 'backend', 'frontend', or another short identifier.",
},
prompt: {
type: "string",
description:
"The full task prompt for the sub-agent. Include all context it needs — sub-agents do not see this conversation.",
},
channelId: {
type: "string",
description:
"Optional channel/thread id the sub-agent should report into. Generated if omitted.",
},
},
required: ["type", "prompt"],
},
},
{
name: "commit_memory",
description:
"Write or update a file in the user's Gitea memory repository. Use for goals, decisions, progress notes, plans, and durable summaries.",
input_schema: {
type: "object",
properties: {
path: {
type: "string",
description:
"Repo-relative path, e.g. 'goals/active.md' or 'decisions/2026-05-19-architecture.md'.",
},
content: {
type: "string",
description: "Full UTF-8 file content to write.",
},
message: {
type: "string",
description: "Commit message describing the change.",
},
},
required: ["path", "content", "message"],
},
},
{
name: "read_memory",
description: "Read a single file from the user's memory repo. Returns null if missing.",
input_schema: {
type: "object",
properties: {
path: { type: "string" },
},
required: ["path"],
},
},
{
name: "list_memory",
description:
"List files at a path prefix in the user's memory repo. Use to discover what context already exists.",
input_schema: {
type: "object",
properties: {
pathPrefix: {
type: "string",
description: "Repo-relative directory, e.g. 'goals' or '' for root.",
},
},
required: ["pathPrefix"],
},
},
];

229
src/lib/llm.ts Normal file
View File

@@ -0,0 +1,229 @@
import { config } from "../config.js";
export const GROW_AGENT_SYSTEM = `You are a Grow Agent - a user's master AI orchestrator on the GrowQR platform.
You own this user's long-running context, memory, and workspace. You coordinate specialized sub-agents (coding, repo, quest, product-flow, etc.), keep durable state in the user's Gitea memory repository, and execute workflows via the user's OpenCode sandbox.
Operating principles:
- Be concise and direct. The user sees your messages in a Slack-like chat.
- Maintain durable memory: commit important decisions, goals, and progress to the user's memory repo using \`commit_memory\`. Read existing context with \`read_memory\` before making suggestions that depend on history.
- For anything that requires code, shell, file edits, or generated artifacts, spawn a sub-agent via \`spawn_sub_agent\`. The sub-agent runs through the user's OpenCode container.
- Track active goals and quests. Surface progress proactively when the user returns.
- Prefer one small commit per meaningful state change over batching.
- Never invent tool names. Only use the tools provided.
`;
export type GrowAgentTool =
| "spawn_sub_agent"
| "commit_memory"
| "read_memory"
| "list_memory";
export type LlmTool = {
type: "function";
function: {
name: GrowAgentTool;
description: string;
parameters: Record<string, unknown>;
};
};
export type LlmToolCall = {
id: string;
name: string;
arguments: Record<string, unknown>;
};
export type LlmMessage = {
role: "system" | "user" | "assistant" | "tool";
content: string | null;
tool_call_id?: string;
tool_calls?: Array<{
id: string;
type: "function";
function: {
name: string;
arguments: string;
};
}>;
};
export const growAgentTools: LlmTool[] = [
{
type: "function",
function: {
name: "spawn_sub_agent",
description:
"Spawn a specialized sub-agent to run a bounded task through the user's OpenCode container. Use for anything that requires running code, editing files, or producing artifacts.",
parameters: {
type: "object",
properties: {
type: {
type: "string",
description:
"Sub-agent type: 'coding', 'repo', 'migration', 'quest', 'product', 'backend', 'frontend', or another short identifier.",
},
prompt: {
type: "string",
description:
"The full task prompt for the sub-agent. Include all context it needs - sub-agents do not see this conversation.",
},
channelId: {
type: "string",
description:
"Optional channel/thread id the sub-agent should report into. Generated if omitted.",
},
},
required: ["type", "prompt"],
},
},
},
{
type: "function",
function: {
name: "commit_memory",
description:
"Write or update a file in the user's Gitea memory repository. Use for goals, decisions, progress notes, plans, and durable summaries.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description:
"Repo-relative path, e.g. 'goals/active.md' or 'decisions/2026-05-19-architecture.md'.",
},
content: {
type: "string",
description: "Full UTF-8 file content to write.",
},
message: {
type: "string",
description: "Commit message describing the change.",
},
},
required: ["path", "content", "message"],
},
},
},
{
type: "function",
function: {
name: "read_memory",
description: "Read a single file from the user's memory repo. Returns null if missing.",
parameters: {
type: "object",
properties: {
path: { type: "string" },
},
required: ["path"],
},
},
},
{
type: "function",
function: {
name: "list_memory",
description:
"List files at a path prefix in the user's memory repo. Use to discover what context already exists.",
parameters: {
type: "object",
properties: {
pathPrefix: {
type: "string",
description: "Repo-relative directory, e.g. 'goals' or '' for root.",
},
},
required: ["pathPrefix"],
},
},
},
];
type ChatCompletionsResponse = {
choices?: Array<{
finish_reason?: string;
message?: {
content?: string | null;
tool_calls?: Array<{
id: string;
type: "function";
function: {
name: string;
arguments?: string;
};
}>;
};
}>;
error?: {
message?: string;
};
};
function normalizeModel(model: string): string {
if (config.llmProvider === "opencode" && model.startsWith("opencode/")) {
return model.slice("opencode/".length);
}
return model;
}
function endpoint(path: string): string {
return `${config.llmBaseUrl.replace(/\/$/, "")}${path}`;
}
function parseToolArguments(raw: string | undefined): Record<string, unknown> {
if (!raw) return {};
try {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object"
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
}
export async function createChatCompletion(opts: {
model: string;
messages: LlmMessage[];
tools: LlmTool[];
maxTokens: number;
}): Promise<{
content: string;
toolCalls: LlmToolCall[];
finishReason?: string;
}> {
const res = await fetch(endpoint("/chat/completions"), {
method: "POST",
headers: {
authorization: `Bearer ${config.llmApiKey}`,
"content-type": "application/json",
accept: "application/json",
},
body: JSON.stringify({
model: normalizeModel(opts.model),
messages: opts.messages,
tools: opts.tools,
tool_choice: "auto",
max_tokens: opts.maxTokens,
}),
});
const json = (await res.json().catch(() => ({}))) as ChatCompletionsResponse;
if (!res.ok) {
const detail = json.error?.message ?? JSON.stringify(json);
throw new Error(`LLM request failed: ${res.status} ${res.statusText}: ${detail}`);
}
const choice = json.choices?.[0];
const message = choice?.message;
return {
content: message?.content ?? "",
finishReason: choice?.finish_reason,
toolCalls:
message?.tool_calls?.map((call) => ({
id: call.id,
name: call.function.name,
arguments: parseToolArguments(call.function.arguments),
})) ?? [],
};
}

12
src/routes/agents.ts Normal file
View File

@@ -0,0 +1,12 @@
import { Hono } from "hono";
import { agentCatalog } from "../agents/catalog.js";
import { requireUser, type AuthContext } from "../auth/clerk.js";
export function agentRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
app.get("/catalog", (c) => c.json({ agents: agentCatalog }));
return app;
}

74
src/routes/workflows.ts Normal file
View File

@@ -0,0 +1,74 @@
import { Hono } from "hono";
import { z } from "zod";
import { createClient } from "rivetkit/client";
import { config } from "../config.js";
import { requireUser, type AuthContext } from "../auth/clerk.js";
import type { Registry } from "../actors/registry.js";
const client = createClient<Registry>(config.rivetEndpoint);
function jobWorkflowFor(userId: string) {
return client.workflowJob.getOrCreate([userId, "job-application"]);
}
export function workflowRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
app.post("/job-application", async (c) => {
const userId = c.get("userId");
const body = z
.object({ goal: z.string().min(1).optional() })
.parse(await c.req.json().catch(() => ({})));
const handle = jobWorkflowFor(userId);
const state = await handle.init({ userId, goal: body.goal });
const started = await handle.start();
return c.json({ workflow: started ?? state });
});
app.get("/job-application", async (c) => {
const userId = c.get("userId");
const handle = jobWorkflowFor(userId);
const state = await handle.init({ userId });
return c.json({ workflow: state });
});
app.post("/job-application/pause", async (c) => {
const userId = c.get("userId");
const workflow = await jobWorkflowFor(userId).pause();
return c.json({ workflow });
});
app.post("/job-application/resume", async (c) => {
const userId = c.get("userId");
const workflow = await jobWorkflowFor(userId).resume();
return c.json({ workflow });
});
app.post("/job-application/agents/:agentId/run", async (c) => {
const userId = c.get("userId");
const agentId = c.req.param("agentId");
const workflow = await jobWorkflowFor(userId).runAgent({ agentId });
return c.json({ workflow });
});
app.post("/job-application/agents/:agentId/score", async (c) => {
const userId = c.get("userId");
const agentId = c.req.param("agentId");
const body = z
.object({
question: z.string().min(1),
answer: z.string().min(1),
score: z.number().min(0).max(100),
notes: z.string().optional(),
})
.parse(await c.req.json());
const workflow = await jobWorkflowFor(userId).recordQaScore({
agentId,
...body,
});
return c.json({ workflow });
});
return app;
}

View File

@@ -0,0 +1,241 @@
import { config } from "../config.js";
import type { AgentProfile } from "../agents/catalog.js";
import { createHash } from "node:crypto";
export type ServiceAgentResult = {
status: "ok" | "unavailable" | "local";
summary: string;
detail?: unknown;
};
export type ServiceAgentContext = {
userId: string;
orgId?: string;
goal: string;
};
function stableUuid(input: string): string {
const hex = createHash("sha256").update(input).digest("hex").slice(0, 32);
return [
hex.slice(0, 8),
hex.slice(8, 12),
`4${hex.slice(13, 16)}`,
`8${hex.slice(17, 20)}`,
hex.slice(20, 32),
].join("-");
}
async function serviceJson<T>(
baseUrl: string,
path: string,
init?: RequestInit,
): Promise<T> {
const res = await fetch(`${baseUrl.replace(/\/$/, "")}${path}`, {
...init,
headers: {
"content-type": "application/json",
...(config.a2aAllowedKey
? { authorization: `Bearer ${config.a2aAllowedKey}` }
: {}),
...(init?.headers ?? {}),
},
});
const body = await res.text();
if (!res.ok) {
throw new Error(`${path} returned HTTP ${res.status}: ${body}`);
}
return (body ? JSON.parse(body) : {}) as T;
}
async function healthCheck(baseUrl: string, label: string): Promise<ServiceAgentResult> {
try {
const detail = await serviceJson<unknown>(baseUrl, "/health", { method: "GET" });
return {
status: "ok",
summary: `${label} is reachable and ready for workflow handoff.`,
detail,
};
} catch (err) {
return {
status: "unavailable",
summary: `${label} is not reachable yet: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
async function runSaraInterview(ctx: ServiceAgentContext): Promise<ServiceAgentResult> {
const payload = {
user_id: ctx.userId,
org_id: ctx.orgId ?? "growqr",
persona_id: "emma",
interview_type: "behavioral",
duration_minutes: 15,
context: {
target_role: ctx.goal,
company_name: "Target company",
difficulty: "medium",
source: "growqr-workflow",
},
};
const detail = await serviceJson<Record<string, unknown>>(
config.interviewServiceUrl,
"/api/v1/configure",
{
method: "POST",
body: JSON.stringify(payload),
},
);
return {
status: "ok",
summary: `Sara created interview session ${detail.session_id ?? "(pending id)"} for ${ctx.goal}.`,
detail,
};
}
async function runEmilyRoleplay(ctx: ServiceAgentContext): Promise<ServiceAgentResult> {
const payload = {
user_id: ctx.userId,
org_id: ctx.orgId ?? "growqr",
persona_id: "emma",
duration_minutes: 15,
roleplay_type: "custom",
brief: `Practice a realistic job-application conversation for: ${ctx.goal}. Include objection handling, concise self-pitching, and a closing next step.`,
metadata: {
target_role: ctx.goal,
difficulty: "medium",
source: "growqr-workflow",
},
qscore: {
q_score: 78,
profession: "student",
formula_version: "workflow-demo",
quotients: {
CQm: { score: 72, active: true },
XQ: { score: 80, active: true },
VQ: { score: 76, active: true },
},
},
};
const detail = await serviceJson<Record<string, unknown>>(
config.roleplayServiceUrl,
"/api/v1/roleplays/configure",
{
method: "POST",
body: JSON.stringify(payload),
},
);
return {
status: "ok",
summary: `Emily created roleplay session ${detail.session_id ?? "(pending id)"} for ${ctx.goal}.`,
detail,
};
}
async function runQuinnQScore(ctx: ServiceAgentContext): Promise<ServiceAgentResult> {
const orgId = ctx.orgId ?? "growqr";
const qscoreUserId = stableUuid(ctx.userId);
const signals = [
{
signal_id: "resume.uploaded",
present: true,
score: 82,
raw: { source: "resume-agent", workflow_goal: ctx.goal },
},
{
signal_id: "resume.ats_compatibility",
present: true,
score: 76,
raw: { source: "resume-agent", workflow_goal: ctx.goal },
},
{
signal_id: "engagement.features_used",
present: true,
score: 88,
raw: { source: "grow-agent", workflow_goal: ctx.goal },
},
{
signal_id: "goals.goal_clarity",
present: true,
score: 90,
raw: { source: "grow-agent", workflow_goal: ctx.goal },
},
];
const ingest = await serviceJson<Record<string, unknown>>(
config.qscoreServiceUrl,
"/v1/signals/ingest",
{
method: "POST",
body: JSON.stringify({
org_id: orgId,
user_id: qscoreUserId,
profession: "student",
source: "growqr-workflow",
signals,
}),
},
);
let compute: Record<string, unknown> | undefined;
try {
compute = await serviceJson<Record<string, unknown>>(
config.qscoreServiceUrl,
"/v1/qscore/compute",
{
method: "POST",
body: JSON.stringify({
org_id: orgId,
user_id: qscoreUserId,
}),
},
);
} catch (err) {
return {
status: "unavailable",
summary:
"Quinn ingested Q-Score signals, but computation is waiting for the QScore worker or formula store.",
detail: {
ingest,
compute_error: err instanceof Error ? err.message : String(err),
},
};
}
return {
status: "ok",
summary: `Quinn computed Q-Score ${compute.q_score ?? "(unknown)"} for ${ctx.goal}.`,
detail: { ingest, compute, qscore_user_id: qscoreUserId },
};
}
export async function runServiceAgentProbe(
agent: AgentProfile,
ctx?: ServiceAgentContext,
): Promise<ServiceAgentResult> {
try {
switch (agent.service) {
case "interview-service":
return ctx
? await runSaraInterview(ctx)
: healthCheck(config.interviewServiceUrl, "Sara / interview-service");
case "roleplay-service":
return ctx
? await runEmilyRoleplay(ctx)
: healthCheck(config.roleplayServiceUrl, "Emily / roleplay-service");
case "qscore-service":
return ctx
? await runQuinnQScore(ctx)
: healthCheck(config.qscoreServiceUrl, "Quinn / qscore-service");
default:
return {
status: "local",
summary: `${agent.name} is a local workflow agent managed by Rivet.`,
};
}
} catch (err) {
return {
status: "unavailable",
summary: `${agent.name} could not complete its service handoff: ${err instanceof Error ? err.message : String(err)}`,
};
}
}