feat: wire real service agents into chat with LLM tool dispatch + Rivet proxy fix #3
22
.env.example
22
.env.example
@@ -9,20 +9,30 @@ POSTGRES_PASSWORD=growqr
|
||||
POSTGRES_DB=growqr
|
||||
|
||||
# Clerk auth — get from dashboard.clerk.com → API Keys
|
||||
CLERK_SECRET_KEY=sk_test_REPLACE_ME
|
||||
CLERK_PUBLISHABLE_KEY=pk_test_REPLACE_ME
|
||||
CLERK_SECRET_KEY=clerk_key
|
||||
CLERK_PUBLISHABLE_KEY=clerk_publishable_key
|
||||
|
||||
# Anthropic — get from console.anthropic.com → API Keys
|
||||
ANTHROPIC_API_KEY=sk-ant-REPLACE_ME
|
||||
GROW_AGENT_MODEL=claude-opus-4-7
|
||||
SUB_AGENT_MODEL=claude-sonnet-4-6
|
||||
# OpenCode Zen LLM gateway — get from opencode.ai/auth
|
||||
OPENCODE_API_KEY=sk-REPLACE_ME
|
||||
LLM_PROVIDER=opencode
|
||||
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
|
||||
|
||||
# Shared secret for actor → backend service calls (rotate in prod)
|
||||
SERVICE_TOKEN=dev-service-token-REPLACE_ME
|
||||
A2A_ALLOWED_KEY=dev-a2a-key
|
||||
|
||||
# Rivet Kit engine (self-hosted in docker-compose)
|
||||
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
|
||||
GITEA_IMAGE=gitea/gitea:1.22
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.data
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
|
||||
@@ -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).
|
||||
- **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.
|
||||
- **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`.
|
||||
@@ -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:
|
||||
|
||||
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`).
|
||||
|
||||
Then:
|
||||
@@ -24,7 +24,7 @@ Then:
|
||||
```bash
|
||||
# Backend 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
|
||||
cd growqr-frontend
|
||||
@@ -73,7 +73,7 @@ Next.js (3000) ──fetch──▶ /users/bootstrap, /users/me (Hono on 40
|
||||
▼
|
||||
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)
|
||||
└─ spawn_sub_agent ────▶ OpenCode container (per user)
|
||||
(multiplexed sessions)
|
||||
|
||||
@@ -54,12 +54,20 @@ services:
|
||||
RIVET_ENDPOINT: http://rivet-engine:6420
|
||||
CLERK_SECRET_KEY: ${CLERK_SECRET_KEY}
|
||||
CLERK_PUBLISHABLE_KEY: ${CLERK_PUBLISHABLE_KEY}
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
|
||||
GROW_AGENT_MODEL: ${GROW_AGENT_MODEL:-claude-opus-4-7}
|
||||
SUB_AGENT_MODEL: ${SUB_AGENT_MODEL:-claude-sonnet-4-6}
|
||||
OPENCODE_API_KEY: ${OPENCODE_API_KEY}
|
||||
LLM_PROVIDER: ${LLM_PROVIDER:-opencode}
|
||||
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}
|
||||
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}
|
||||
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_DATA_ROOT: /data/users
|
||||
USER_PORT_RANGE_START: 20000
|
||||
|
||||
@@ -366,7 +366,7 @@
|
||||
<span class="swatch" style="background: var(--c-memory-bg)"></span>
|
||||
<div>
|
||||
<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 class="item" style="color: var(--c-git)">
|
||||
|
||||
156
package-lock.json
generated
156
package-lock.json
generated
@@ -8,7 +8,6 @@
|
||||
"name": "growqr-backend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.96.0",
|
||||
"@clerk/backend": "^1.21.0",
|
||||
"@hono/node-server": "^1.13.7",
|
||||
"dockerode": "^4.0.7",
|
||||
@@ -38,36 +37,6 @@
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz",
|
||||
@@ -244,7 +213,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -261,7 +229,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -278,7 +245,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -295,7 +261,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -312,7 +277,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -329,7 +293,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -346,7 +309,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -363,7 +325,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -380,7 +341,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -397,7 +357,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -414,7 +373,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -431,7 +389,6 @@
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -448,7 +405,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -465,7 +421,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -482,7 +437,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -499,7 +453,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -516,7 +469,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -533,7 +485,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -550,7 +501,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -567,7 +517,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -584,7 +533,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -601,7 +549,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -668,7 +615,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -685,7 +631,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -702,7 +647,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -719,7 +663,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -736,7 +679,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -753,7 +695,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -770,7 +711,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -787,7 +727,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -804,7 +743,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -821,7 +759,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -838,7 +775,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -855,7 +791,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -872,7 +807,6 @@
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -889,7 +823,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -906,7 +839,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -923,7 +855,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -940,7 +871,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -957,7 +887,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -974,7 +903,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -991,7 +919,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1008,7 +935,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1025,7 +951,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1042,7 +967,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1059,7 +983,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1076,7 +999,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1093,7 +1015,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1157,7 +1078,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
},
|
||||
@@ -2096,12 +2016,6 @@
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
||||
@@ -2305,7 +2219,6 @@
|
||||
"integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
@@ -2960,7 +2873,6 @@
|
||||
"resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.12.tgz",
|
||||
"integrity": "sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@balena/dockerignore": "^1.0.2",
|
||||
"@grpc/grpc-js": "^1.11.1",
|
||||
@@ -3014,7 +2926,6 @@
|
||||
"integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@drizzle-team/brocli": "^0.10.2",
|
||||
"@esbuild-kit/esm-loader": "^2.5.5",
|
||||
@@ -3032,7 +2943,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3049,7 +2959,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3066,7 +2975,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3083,7 +2991,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3100,7 +3007,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3117,7 +3023,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3134,7 +3039,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3151,7 +3055,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3168,7 +3071,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3185,7 +3087,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3202,7 +3103,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3219,7 +3119,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3236,7 +3135,6 @@
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3253,7 +3151,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3270,7 +3167,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3287,7 +3183,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3304,7 +3199,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3321,7 +3215,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3338,7 +3231,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3355,7 +3247,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3372,7 +3263,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3389,7 +3279,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3406,7 +3295,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3423,7 +3311,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3440,7 +3327,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3457,7 +3343,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3811,12 +3696,6 @@
|
||||
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fdb-tuple/-/fdb-tuple-1.0.0.tgz",
|
||||
@@ -3870,7 +3749,6 @@
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -3937,7 +3815,6 @@
|
||||
"resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz",
|
||||
"integrity": "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
@@ -4091,7 +3968,6 @@
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.19.tgz",
|
||||
"integrity": "sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -4314,19 +4190,6 @@
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"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": {
|
||||
"version": "6.0.0",
|
||||
"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",
|
||||
"integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==",
|
||||
"license": "Unlicense",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -5380,7 +5242,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -5774,16 +5635,6 @@
|
||||
"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": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||
@@ -5972,12 +5823,6 @@
|
||||
"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": {
|
||||
"version": "2.8.1",
|
||||
"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",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"compose:down": "docker compose down"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.96.0",
|
||||
"@clerk/backend": "^1.21.0",
|
||||
"@hono/node-server": "^1.13.7",
|
||||
"dockerode": "^4.0.7",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { actor } from "rivetkit";
|
||||
import type Anthropic from "@anthropic-ai/sdk";
|
||||
import { log } from "../log.js";
|
||||
import { config } from "../config.js";
|
||||
import {
|
||||
anthropic,
|
||||
createChatCompletion,
|
||||
GROW_AGENT_SYSTEM,
|
||||
growAgentTools,
|
||||
} from "../lib/anthropic.js";
|
||||
type LlmMessage,
|
||||
type LlmToolCall,
|
||||
} from "../lib/llm.js";
|
||||
import {
|
||||
provisionUserStack,
|
||||
getUserStack,
|
||||
@@ -18,9 +19,10 @@ import { db } from "../db/client.js";
|
||||
import { actors as actorsTable, events as eventsTable } from "../db/schema.js";
|
||||
|
||||
type ChatTurn = {
|
||||
role: "user" | "assistant";
|
||||
// Anthropic content blocks; "user" turns may also be plain strings.
|
||||
content: string | Anthropic.ContentBlockParam[];
|
||||
role: "user" | "assistant" | "tool";
|
||||
content: string;
|
||||
toolCallId?: string;
|
||||
toolCalls?: LlmToolCall[];
|
||||
};
|
||||
|
||||
type GrowAgentState = {
|
||||
@@ -73,7 +75,7 @@ export const growAgent = actor({
|
||||
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 }) => {
|
||||
if (!c.state.userId) {
|
||||
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(
|
||||
c: {
|
||||
state: GrowAgentState;
|
||||
@@ -134,9 +137,9 @@ async function runAgentLoop(
|
||||
},
|
||||
userId: string,
|
||||
): Promise<string> {
|
||||
if (!config.anthropicApiKey) {
|
||||
if (!config.llmApiKey) {
|
||||
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.broadcast("message", { role: "agent", text: reply });
|
||||
return reply;
|
||||
@@ -148,78 +151,80 @@ async function runAgentLoop(
|
||||
let assistantTextOut = "";
|
||||
|
||||
for (let i = 0; i < MAX_ITERATIONS; i++) {
|
||||
const response = await anthropic.messages.create({
|
||||
const response = await createChatCompletion({
|
||||
model: config.growAgentModel,
|
||||
max_tokens: config.maxAgentTokens,
|
||||
system: [
|
||||
{
|
||||
type: "text",
|
||||
text: GROW_AGENT_SYSTEM,
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
],
|
||||
maxTokens: config.maxAgentTokens,
|
||||
tools: growAgentTools,
|
||||
thinking: { type: "adaptive" },
|
||||
messages: messagesForApi(c.state.history),
|
||||
});
|
||||
|
||||
// Capture assistant text for streaming-style broadcast.
|
||||
for (const block of response.content) {
|
||||
if (block.type === "text" && block.text) {
|
||||
assistantTextOut += (assistantTextOut ? "\n\n" : "") + block.text;
|
||||
c.broadcast("message", { role: "agent", text: block.text });
|
||||
}
|
||||
if (response.content) {
|
||||
assistantTextOut += (assistantTextOut ? "\n\n" : "") + response.content;
|
||||
c.broadcast("message", { role: "agent", text: response.content });
|
||||
}
|
||||
|
||||
// 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({
|
||||
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;
|
||||
}
|
||||
|
||||
const toolUses = response.content.filter(
|
||||
(b): b is Anthropic.ToolUseBlock => b.type === "tool_use",
|
||||
);
|
||||
if (toolUses.length === 0) break;
|
||||
|
||||
const toolResults: Anthropic.ToolResultBlockParam[] = [];
|
||||
for (const call of toolUses) {
|
||||
for (const call of response.toolCalls) {
|
||||
try {
|
||||
const result = await dispatchTool(c, userId, call);
|
||||
toolResults.push({
|
||||
type: "tool_result",
|
||||
tool_use_id: call.id,
|
||||
c.state.history.push({
|
||||
role: "tool",
|
||||
toolCallId: call.id,
|
||||
content: typeof result === "string" ? result : JSON.stringify(result),
|
||||
});
|
||||
} catch (err) {
|
||||
log.error({ err, tool: call.name }, "tool dispatch failed");
|
||||
toolResults.push({
|
||||
type: "tool_result",
|
||||
tool_use_id: call.id,
|
||||
c.state.history.push({
|
||||
role: "tool",
|
||||
toolCallId: call.id,
|
||||
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" });
|
||||
return assistantTextOut || "(no response)";
|
||||
}
|
||||
|
||||
function messagesForApi(
|
||||
history: ChatTurn[],
|
||||
): Anthropic.MessageParam[] {
|
||||
return history.map((t) => ({
|
||||
role: t.role,
|
||||
content: t.content,
|
||||
})) as Anthropic.MessageParam[];
|
||||
function messagesForApi(history: ChatTurn[]): LlmMessage[] {
|
||||
const messages: LlmMessage[] = [
|
||||
{ role: "system", content: GROW_AGENT_SYSTEM },
|
||||
];
|
||||
for (const turn of history) {
|
||||
if (turn.role === "tool") {
|
||||
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(
|
||||
@@ -228,9 +233,9 @@ async function dispatchTool(
|
||||
state: GrowAgentState;
|
||||
},
|
||||
userId: string,
|
||||
call: Anthropic.ToolUseBlock,
|
||||
call: LlmToolCall,
|
||||
): Promise<unknown> {
|
||||
const input = call.input as Record<string, unknown>;
|
||||
const input = call.arguments;
|
||||
switch (call.name) {
|
||||
case "spawn_sub_agent": {
|
||||
const type = String(input.type ?? "generic");
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { setup } from "rivetkit";
|
||||
import { growAgent } from "./grow-agent.js";
|
||||
import { subAgent } from "./sub-agent.js";
|
||||
import { workflowJob } from "./workflow-job.js";
|
||||
|
||||
export const registry = setup({
|
||||
use: {
|
||||
growAgent,
|
||||
subAgent,
|
||||
workflowJob,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
292
src/actors/workflow-job.ts
Normal file
292
src/actors/workflow-job.ts
Normal 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
94
src/agents/catalog.ts
Normal 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",
|
||||
];
|
||||
}
|
||||
@@ -34,7 +34,19 @@ export const requireUser = createMiddleware<AuthContext>(async (c, next) => {
|
||||
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) {
|
||||
throw new HTTPException(401, { message: "service token references unknown user" });
|
||||
}
|
||||
|
||||
@@ -23,21 +23,46 @@ export const config = {
|
||||
clerkPublishableKey: process.env.CLERK_PUBLISHABLE_KEY ?? "",
|
||||
// Optional: lock service-to-service calls (actor → backend).
|
||||
serviceToken: process.env.SERVICE_TOKEN ?? "",
|
||||
a2aAllowedKey: process.env.A2A_ALLOWED_KEY ?? "dev-a2a-key",
|
||||
|
||||
// Anthropic for Grow Agent + sub-agent LLM calls.
|
||||
anthropicApiKey: process.env.ANTHROPIC_API_KEY ?? "",
|
||||
// LLM gateway for Grow Agent + sub-agent planning calls.
|
||||
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:
|
||||
process.env.GROW_AGENT_MODEL ?? "claude-opus-4-7",
|
||||
process.env.GROW_AGENT_MODEL ??
|
||||
process.env.LLM_MODEL ??
|
||||
"kimi-k2.6",
|
||||
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).
|
||||
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.
|
||||
giteaImage: process.env.GITEA_IMAGE ?? "gitea/gitea:1.22",
|
||||
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).
|
||||
userContainerHost: process.env.USER_CONTAINER_HOST ?? "127.0.0.1",
|
||||
@@ -49,7 +74,7 @@ export const config = {
|
||||
frontendOrigin:
|
||||
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),
|
||||
|
||||
required, // exported so other modules can fail fast on boot
|
||||
|
||||
@@ -133,42 +133,95 @@ async function startGiteaContainer(opts: {
|
||||
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.
|
||||
// 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: {
|
||||
containerId: string;
|
||||
username: string;
|
||||
password: string;
|
||||
email: string;
|
||||
}): Promise<void> {
|
||||
const container = docker.getContainer(opts.containerId);
|
||||
const exec = await container.exec({
|
||||
Cmd: [
|
||||
"su",
|
||||
"git",
|
||||
"-c",
|
||||
`gitea admin user create --admin --username ${opts.username} --password '${opts.password.replace(/'/g, "'\\''")}' --email ${opts.email} --must-change-password=false`,
|
||||
],
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
WorkingDir: "/var/lib/gitea",
|
||||
});
|
||||
const stream = await exec.start({ Detach: false, Tty: false });
|
||||
await new Promise<void>((resolve) => {
|
||||
stream.on("end", () => resolve());
|
||||
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.
|
||||
try {
|
||||
await execGiteaCli(opts.containerId, [
|
||||
"admin",
|
||||
"user",
|
||||
"create",
|
||||
"--admin",
|
||||
"--username",
|
||||
opts.username,
|
||||
"--password",
|
||||
opts.password,
|
||||
"--email",
|
||||
opts.email,
|
||||
"--must-change-password=false",
|
||||
]);
|
||||
} catch (err) {
|
||||
log.debug(
|
||||
{ exitCode: info.ExitCode },
|
||||
{ err },
|
||||
"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: {
|
||||
userId: string;
|
||||
httpPort: number;
|
||||
@@ -298,15 +351,11 @@ export async function provisionUserStack(userId: string): Promise<UserStack> {
|
||||
email: adminEmail,
|
||||
});
|
||||
|
||||
// Mint a token using basic auth.
|
||||
const basicClient = new GiteaClient(giteaBase, {
|
||||
kind: "basic",
|
||||
// Mint a token via Gitea's CLI so retries do not depend on a transient
|
||||
// bootstrap password from a previous provisioning attempt.
|
||||
const token = await generateGiteaToken({
|
||||
containerId: gitea.id,
|
||||
username: adminUsername,
|
||||
password: adminPassword,
|
||||
});
|
||||
const token = await basicClient.ensureAccessToken({
|
||||
username: adminUsername,
|
||||
name: "growqr-backend",
|
||||
scopes: ["write:repository", "write:user", "write:issue"],
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import { actorRoutes } from "./routes/actors.js";
|
||||
import { opencodeRoutes } from "./routes/opencode.js";
|
||||
import { gitRoutes } from "./routes/git.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 { hydratePortAllocator, reconcileOnBoot } from "./docker/manager.js";
|
||||
|
||||
@@ -58,10 +60,17 @@ async function main() {
|
||||
|
||||
// PRD HTTP control plane (auth-gated).
|
||||
app.route("/users", userRoutes());
|
||||
app.route("/agents", agentRoutes());
|
||||
app.route("/workflows", workflowRoutes());
|
||||
app.route("/actors", actorRoutes());
|
||||
app.route("/opencode", opencodeRoutes());
|
||||
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) => {
|
||||
log.info(
|
||||
{
|
||||
|
||||
@@ -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
229
src/lib/llm.ts
Normal 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
12
src/routes/agents.ts
Normal 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
74
src/routes/workflows.ts
Normal 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;
|
||||
}
|
||||
241
src/services/service-agents.ts
Normal file
241
src/services/service-agents.ts
Normal 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)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user