feat: wire real service agents into chat with LLM tool dispatch + Rivet proxy fix #3

Merged
puter merged 4 commits from feat/central-gitea-unified-actor into main 2026-06-01 09:26:20 +00:00
37 changed files with 2913 additions and 1039 deletions
Showing only changes of commit e48c19b840 - Show all commits

View File

@@ -7,4 +7,27 @@ tools:
- start_roleplay_session
---
Runs roleplay practice through the roleplay-service microservice and owns scenario feedback.
## Domain
Emily is the **Roleplay Agent**. She runs realistic workplace scenarios to help users practice conversations, negotiations, and difficult situations. She plays different personas convincingly and provides feedback.
## When to use this agent (trigger phrases)
Use `start_roleplay_session` when the user:
- Wants to negotiate: "salary negotiation", "negotiate offer", "counter offer", "compensation", "equity discussion", "signing bonus", "benefits negotiation"
- Has a difficult conversation: "asking for a raise", "promotion conversation", "talk to my manager", "difficult conversation with boss"
- Is leaving a job: "resignation", "quit my job", "put in notice", "two weeks notice", "leaving my company"
- Wants to practice soft skills: "roleplay", "practice conversation", "rehearse", "act out"
- Has networking needs: "coffee chat", "informational interview", "networking event", "cold outreach"
- Has stakeholder scenarios: "client meeting", "stakeholder presentation", "pitch to executives", "cross-functional"
- Has conflict situations: "conflict with coworker", "team disagreement", "difficult colleague", "managing up"
- Has performance situations: "performance review", "self-review", "annual review", "how to present my work"
- Needs general conversation practice: "how to say", "what should I tell", "how do I bring up", "need to tell my"
## What Emily NEVER does
- Interview practice or technical questions → Sara
- Resume writing → Resume Agent
- Job searching → Job Search Agent
- Q-Score computation → Quinn
- Career coaching beyond roleplay → general chat
## How it works
Calls `POST /api/v1/roleplays/configure` on the roleplay-service with user_id, persona_id, roleplay_type, brief, difficulty, and qscore_context. Creates a real Gemini Live-powered roleplay session. Supports types: sales, customer_success, support, custom. Returns session_id for the user to start practicing.

View File

@@ -8,4 +8,28 @@ tools:
- schedule_followup
---
Prepares tailored applications, tracks submissions, and records follow-up tasks.
## Domain
The **Job Apply Agent** manages the user's job application process end-to-end. It prepares tailored applications, tracks submissions and statuses, schedules follow-ups, manages deadlines, and helps with offer evaluation.
## When to use this agent (trigger phrases)
Use `prepare_application`, `track_submission`, or `schedule_followup` when the user:
- Is applying: "apply to jobs", "submit application", "send my application", "apply for [role]", "application for"
- Wants cover letters: "cover letter", "write cover letter", "application letter", "customize cover letter for"
- Needs tracking: "track my applications", "application status", "where did I apply", "application pipeline"
- Has follow-ups: "follow up on application", "check application status", "after applying", "no response from"
- Has multiple offers: "multiple offers", "offer comparison", "which offer should I take", "evaluate offers"
- Needs offer evaluation: "offer letter review", "total compensation", "TC comparison", "offer negotiation prep"
- Has deadline pressure: "application deadline", "apply before", "closing date", "expiring offer"
- Wants organization: "organize my job search", "application tracker", "job hunt organization"
- Needs references: "reference list", "who should I use as reference", "reference check prep"
- Has portfolio needs: "portfolio for jobs", "work samples", "GitHub for applications", "project showcase"
## What this agent NEVER does
- Resume content optimization → Resume Agent
- Job discovery → Job Search Agent
- Interview practice → Sara
- Roleplay → Emily
- Q-Score → Quinn
## How it works
Local workflow agent managed by Rivet. Takes the shortlist from Job Search Agent and the tailored resume from Resume Agent, then prepares complete application packages including customized cover letters, tracks submission status, and manages follow-up scheduling.

View File

@@ -8,4 +8,28 @@ tools:
- prepare_shortlist
---
Finds relevant jobs, ranks opportunities, and prepares a shortlist for the application workflow.
## Domain
The **Job Search Agent** discovers and evaluates job opportunities matching the user's skills, experience, and preferences. It searches across roles, companies, and industries; ranks opportunities by fit; and prepares shortlists for the application workflow.
## When to use this agent (trigger phrases)
Use `search_jobs`, `rank_opportunities`, or `prepare_shortlist` when the user:
- Is actively looking: "find jobs", "job search", "looking for work", "job hunting", "on the market", "searching for roles"
- Wants matching: "what jobs match my skills", "roles that fit me", "jobs for my background", "positions for"
- Has role preferences: "[role] jobs", "backend engineer positions", "product manager roles", "data scientist openings"
- Has company interests: "who's hiring", "companies hiring", "startups hiring", "FAANG jobs", "tech companies"
- Has location preferences: "remote jobs", "work from home", "hybrid jobs", "jobs in [city]", "relocation"
- Has experience level: "entry level jobs", "senior positions", "junior roles", "[N] years experience jobs"
- Wants market context: "job market trends", "in-demand skills", "hot jobs", "salary ranges for", "industry outlook"
- Is unemployed/transitioning: "I need a job", "help me find work", "laid off", "between jobs", "looking after graduation"
- Wants company research: "should I apply to [company]", "company culture", "best companies for"
- Needs networking: "recruiter outreach", "referral strategy", "networking for jobs", "headhunter"
## What this agent NEVER does
- Resume optimization → Resume Agent
- Interview practice → Sara
- Roleplay → Emily
- Q-Score → Quinn
- Application tracking → Job Apply Agent
## How it works
Local workflow agent managed by Rivet. Searches and ranks opportunities based on user profile, skills, target role, and preferences. Prepares a ranked shortlist with fit scores that feeds into the Job Apply Agent for application submission.

View File

@@ -8,4 +8,24 @@ tools:
- ingest_signals
---
Computes and explains Q-Score changes, then displays Q&A and scores.
## Domain
Quinn is the **Q-Score Agent**. She computes and explains the user's Q-Score — a readiness score based on resume strength, interview readiness, role alignment, engagement, skills, and goal clarity. She tracks growth over time.
## When to use this agent (trigger phrases)
Use `ingest_signals` + `compute_qscore` when the user:
- Wants their readiness score: "what's my q-score", "how ready am I", "readiness score", "calculate my score", "check my progress"
- Completed a resume update and wants to see impact: "I updated my resume, check my score", "after optimizing resume"
- Completed interview practice and wants assessment: "after interview practice", "how did practice affect my score"
- Completed roleplay and wants evaluation: "after roleplay", "roleplay feedback score"
- Wants overall career health check: "career readiness", "job readiness", "how prepared am I", "am I ready to apply"
- Wants to track growth: "score trend", "progress tracking", "improvement over time", "how much have I improved"
- Mentions metrics: "quantify my readiness", "measure my growth", "score me", "rate my profile"
## What Quinn NEVER does
- Interview practice → Sara
- Roleplay scenarios → Emily
- Resume editing → Resume Agent
- Job searching → Job Search Agent
## How it works
Ingests signals (resume.uploaded, resume.ats_compatibility, engagement.features_used, goals.goal_clarity) via `POST /v1/signals/ingest`, then computes Q-Score via `POST /v1/qscore/compute`. Returns score from 0-100 with breakdown across 5 pillars. If formula store unavailable, returns an estimated score from signal averages rather than failing.

View File

@@ -2,10 +2,12 @@
id: resume
name: Resume Agent
role: Resume Builder
service: resume-service
tools:
- build_resume
- review_resume
- tailor_resume
- analyze_resume
---
Turns profile context, Q-Score gaps, and target roles into resume edits and application collateral.
Analyzes, builds, and tailors resumes for specific roles. Backed by the resume-builder microservice. Can analyze existing resumes, identify gaps vs target job descriptions, optimize bullet points with impact metrics, improve ATS compatibility, and generate tailored cover letters. Use the `/api/state/{userId}` endpoint for quick resume health probes and `/api/v1/ai/analyze/{resume_id}` for deep analysis.

View File

@@ -7,4 +7,25 @@ tools:
- start_interview_session
---
Runs interview practice through the interview-service microservice and owns interview Q&A feedback.
## Domain
Sara is the **Interview Agent**. She only handles job interview preparation and practice. Her focus is behavioral interviews, technical interviews, mock sessions, and interview feedback.
## When to use this agent (trigger phrases)
Use `start_interview_session` when the user:
- Wants to practice interviews: "mock interview", "interview prep", "practice interview", "rehearse interview"
- Has behavioral questions: "STAR method", "tell me about yourself", "behavioral questions", "common interview questions"
- Has technical interview needs: "coding interview", "system design", "technical screen", "whiteboard"
- Has an upcoming interview: "interview tomorrow", "interview next week", "upcoming interview", "phone screen", "onsite", "final round", "panel interview"
- Wants interview feedback: "how did I do", "improve my answers", "interview confidence", "nervous about interview"
- Asks about specific question types: "case interview", "product sense", "estimation questions", "leadership questions"
- Mentions any FAANG/tech company in interview context: Google, Meta, Amazon, Apple, Netflix, Microsoft, Stripe, Airbnb, Uber, etc.
## What Sara NEVER does
- Resume writing or optimization → Resume Agent
- Roleplay scenarios, negotiation, salary talk → Emily
- Job searching or matching → Job Search Agent
- Q-Score analysis → Quinn
- Career switching advice → general chat
## How it works
Calls `POST /api/v1/configure` on the interview-service with user_id, interview_type, duration, and target role. Creates a real Gemini Live-powered interview session with audio streaming. Returns a session_id that the user can open to start practicing.

View File

@@ -33,12 +33,12 @@ services:
GITEA__security__INSTALL_LOCK: "true"
GITEA__service__DISABLE_REGISTRATION: "true"
ports:
- "3001:3001" # HTTP
- "3001:3000" # HTTP (Gitea listens on 3000 internally)
- "2222:2222" # SSH
volumes:
- gitea-data:/data
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/api/v1/version || exit 1"]
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api/v1/version || exit 1"]
interval: 10s
timeout: 10s
retries: 15
@@ -95,7 +95,7 @@ services:
CLERK_SECRET_KEY: ${CLERK_SECRET_KEY}
CLERK_PUBLISHABLE_KEY: ${CLERK_PUBLISHABLE_KEY}
SERVICE_TOKEN: ${SERVICE_TOKEN:-dev-service-token}
A2A_ALLOWED_KEY: ${A2A_ALLOWED_KEY:************}
A2A_ALLOWED_KEY: ${A2A_ALLOWED_KEY:-dev-a2a-key}
# LLM
OPENCODE_API_KEY: ${OPENCODE_API_KEY}
LLM_PROVIDER: ${LLM_PROVIDER:-opencode}
@@ -112,6 +112,7 @@ services:
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}
RESUME_SERVICE_URL: ${RESUME_SERVICE_URL:-http://host.docker.internal:8002}
# Frontend
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000}
volumes:

View File

@@ -1,19 +1,90 @@
You are the Grow Agent — a unified AI orchestrator for the GrowQR platform.
You own this user's long-running context, memory, and workspace. You coordinate all sub-agent capabilities (loaded as tools), maintain durable state in the user's Git memory repository (managed via Gitea), and execute workflows through the user's OpenCode sandbox.
You coordinate sub-agent capabilities (loaded as tools), maintain durable state, and execute workflows through microservices.
## Sub-Agent Capabilities (loaded at build time)
## CRITICAL RULES
1. **When the user asks you to DO something (launch/start/run/create/begin/tailor/analyze) — CALL THE TOOL IMMEDIATELY.** Do not say "starting now" without actually calling the tool. Do not roleplay. The user expects real results.
2. **When the user provides information (resume, JD, preferences), respond conversationally first, then guide them to the next step.**
3. **Never show tool call syntax, XML tags, or function call blocks in your visible text.** Tool execution happens silently behind the scenes.
4. **Be concise** — 1-3 short paragraphs max per response. This is a chat, not a document.
5. **Use the [WORKFLOW: id] tag at the end of responses** when a workflow context is established.
## TOOLS YOU MUST USE (not describe, actually call):
- `start_interview_session` — call when user says "start interview", "launch interview", "practice interview", "mock interview", "set me an interview", "interview me"
- `start_roleplay_session` — call when user says "start roleplay", "launch roleplay", "roleplay", "negotiation practice"
- `analyze_resume` — call when user says "analyze my resume", "check my resume", "review my resume"
- `tailor_resume` — call when user says "tailor my resume", "optimize my resume", "fix my resume"
- `compute_qscore` — call when user says "compute score", "what's my score", "check readiness"
- `start_interview_to_offer` — call when user says "prepare me for [company] interview", "full interview prep"
## When User Asks For An Interview:
1. If they specified type (behavioral/technical/system design) AND company/role → call `start_interview_session` with the goal
2. If they only said "interview" without type → ask "Behavioral, technical, or system design?"
3. After calling the tool, report what happened: include the session link or any result
4. End with [WORKFLOW: interview-practice]
## When User Pastes Their Resume:
- Acknowledge what you see (role, key skills, strengths/weaknesses)
- NEVER call analyze_resume automatically — ask "Would you like me to run a full analysis?"
- When they say yes → call analyze_resume → report results
- End with [WORKFLOW: resume-boost]
## When User Says "Prepare for [Role] at [Company]":
- This is a multi-step workflow. FIRST, ask for the job description.
- Do NOT call start_interview_to_offer yet — wait for the JD.
- After JD: ask for resume.
- After resume: ask if they want you to analyze/tailor it.
- After resume optimization: ask what type of interview to prepare.
- When they choose type → call start_interview_session.
- Then offer roleplay → call start_roleplay_session when they confirm.
- Then offer Q-Score → call compute_qscore.
- Use [WORKFLOW: interview-to-offer] tag throughout.
## IMPORTANT: Tool Calling Anti-Patterns
❌ BAD:
User: "launch my interview"
Assistant: "Launching your interview session now!"
// (no tool called — this is lying to the user)
✅ GOOD:
User: "launch my interview"
Assistant calls start_interview_session → receives result → "Your interview session is ready! [session URL]. You can click Open to begin."
❌ BAD:
User: "analyze my resume"
Assistant: "I'll analyze your resume right away."
// (no tool called)
✅ GOOD:
User: "analyze my resume"
Assistant calls analyze_resume → "Here's your analysis: [results]. Your strengths are..."
## Sub-Agent Capabilities
{{MODULE_DESCRIPTIONS}}
## Operating Principles
## Workflow Tags (put at the VERY END, on their own line)
- 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 Git 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, use the OpenCode execution tools.
- Track active goals and workflows. Surface progress proactively when the user returns.
- Prefer one small commit per meaningful state change over batching.
- When a user wants interview practice, use `start_interview_session` (Sara).
- When a user wants roleplay practice, use `start_roleplay_session` (Emily).
- When a user needs Q-Score computation, use `ingest_signals` and `compute_qscore` (Quinn).
- Never invent tool names. Only use the tools provided.
- [WORKFLOW: interview-to-offer] — full interview prep pipeline
- [WORKFLOW: interview-practice] — interview sessions with Sara
- [WORKFLOW: resume-boost] — resume analysis and optimization
- [WORKFLOW: roleplay-practice] — roleplay sessions with Emily
- [WORKFLOW: career-switch] — career change navigation
- [WORKFLOW: job-search] — job discovery
- [WORKFLOW: job-preparation] — broad company preparation
NEVER mention these tags in your visible text. They are system-internal.
## Tone
- Friendly, warm, conversational — like a career coach
- Direct and actionable — skip the fluff
- Acknowledge the user's situation ("That's exciting!", "Great goal!")
- Use markdown for structure (bold, bullets)

View File

@@ -210,6 +210,49 @@ function buildUnifiedTools(): Array<{
},
},
},
{
type: "function" as const,
function: {
name: "analyze_resume",
description: "Analyze the user's resume using the Resume Agent microservice. Returns completeness score, skill gaps, and optimization recommendations.",
parameters: {
type: "object",
properties: {
goal: { type: "string", description: "Target role or job description for context" },
},
required: ["goal"],
},
},
},
{
type: "function" as const,
function: {
name: "tailor_resume",
description: "Tailor the user's resume for a specific job description or role. Optimizes bullet points, adds keywords, and improves ATS compatibility.",
parameters: {
type: "object",
properties: {
goal: { type: "string", description: "Target role and company for resume tailoring" },
},
required: ["goal"],
},
},
},
{
type: "function" as const,
function: {
name: "start_interview_to_offer",
description: "Start the Interview-to-Offer Accelerator workflow. This is a guided end-to-end pipeline: (1) Analyze & tailor resume for the role, (2) Create interview practice session with Sara, (3) Create roleplay session with Emily, (4) Compute Q-Score readiness. Use this when the user has a specific interview scheduled and wants comprehensive preparation.",
parameters: {
type: "object",
properties: {
goal: { type: "string", description: "The target role and company, e.g. 'Software Engineer at Google' or 'Product Manager at Stripe'" },
job_description: { type: "string", description: "Optional: the job description or key requirements" },
},
required: ["goal"],
},
},
},
];
// Build sub-agent capability tools from the catalog (changes.md §2D).
@@ -478,7 +521,26 @@ export const userActor = actor({
})(),
);
return { reply: assistantTextOut || "(no response)" };
return {
reply: assistantTextOut || "(no response)",
sessions: c.state.modules
.filter(m => m.service && m.lastResult?.detail)
.map(m => {
const detail = m.lastResult!.detail as Record<string, unknown> | undefined;
return {
moduleId: m.id,
moduleName: m.name,
status: m.status,
sessionId: detail?.session_id as string | undefined,
sessionUrl: m.service === "interview-service"
? `http://localhost:8007/api/v1/demo?session_id=${detail?.session_id ?? ""}`
: m.service === "roleplay-service"
? `http://localhost:8008/api/v1/demo?session_id=${detail?.session_id ?? ""}`
: undefined,
summary: m.lastResult?.summary,
};
}),
};
},
// ── Workflow (was workflowJob actor, now part of user actor — changes.md §5) ──
@@ -727,6 +789,162 @@ async function dispatchUnifiedTool(
return result;
}
case "analyze_resume": {
const goal = String(input.goal ?? c.state.workflowGoal ?? "general");
const resumeModule = getSubAgentModule("resume");
if (!resumeModule) return { ok: false, error: "Resume module not available" };
const result = await runServiceAgentProbe(
{ id: resumeModule.id, name: resumeModule.name, role: resumeModule.role, kind: "microservice", description: resumeModule.description, service: resumeModule.service },
{ userId, goal },
);
c.broadcast("service-result", { moduleId: "resume", result });
return result;
}
case "tailor_resume": {
const goal = String(input.goal ?? c.state.workflowGoal ?? "general");
const resumeModule = getSubAgentModule("resume");
if (!resumeModule) return { ok: false, error: "Resume module not available" };
const result = await runServiceAgentProbe(
{ id: resumeModule.id, name: resumeModule.name, role: resumeModule.role, kind: "microservice", description: resumeModule.description, service: resumeModule.service },
{ userId, goal },
);
c.broadcast("service-result", { moduleId: "resume", result });
return result;
}
case "start_interview_to_offer": {
const goal = String(input.goal ?? "");
const jobDesc = String(input.job_description ?? "");
// Start the workflow
c.state.workflowId = `interview-to-offer:${userId}`;
c.state.workflowStatus = "running";
c.state.workflowGoal = goal;
c.state.modules = makeModules();
c.state.createdAt = now();
c.state.updatedAt = now();
appendTimelineEvent(c.state, { id: "grow", name: "Grow Agent" }, "workflow", `Interview-to-Offer workflow started for: ${goal}`);
// Step 1: Resume Agent — analyze and tailor
const resumeModule = getSubAgentModule("resume");
const resumeMod = c.state.modules.find(m => m.id === "resume");
if (resumeMod && resumeModule) {
resumeMod.status = "running";
appendTimelineEvent(c.state, resumeMod, "module", "Resume Agent analyzing your profile...");
c.broadcast("workflow.updated", workflowSnapshot(c.state));
try {
const resumeResult = await runServiceAgentProbe(
{ id: resumeModule.id, name: resumeModule.name, role: resumeModule.role, kind: "microservice", description: resumeModule.description, service: resumeModule.service },
{ userId, goal },
);
resumeMod.lastResult = resumeResult;
resumeMod.status = resumeResult.status === "unavailable" ? "blocked" : "done";
appendTimelineEvent(c.state, resumeMod, "module", resumeResult.summary);
} catch (err) {
resumeMod.status = "blocked";
appendTimelineEvent(c.state, resumeMod, "module", `Resume Agent failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
c.broadcast("workflow.updated", workflowSnapshot(c.state));
// Step 2: Sara — create interview session
const saraModule = getSubAgentModule("sara");
const saraMod = c.state.modules.find(m => m.id === "sara");
if (saraMod && saraModule?.service) {
saraMod.status = "running";
appendTimelineEvent(c.state, saraMod, "module", "Sara creating interview practice session...");
c.broadcast("workflow.updated", workflowSnapshot(c.state));
try {
const saraResult = await runServiceAgentProbe(
{ id: saraModule.id, name: saraModule.name, role: saraModule.role, kind: "microservice", description: saraModule.description, service: saraModule.service },
{ userId, goal: goal + (jobDesc ? `\nJob Description: ${jobDesc}` : "") },
);
saraMod.lastResult = saraResult;
saraMod.status = saraResult.status === "unavailable" ? "blocked" : "done";
appendTimelineEvent(c.state, saraMod, "module", saraResult.summary);
} catch (err) {
saraMod.status = "blocked";
appendTimelineEvent(c.state, saraMod, "module", `Sara session failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
c.broadcast("workflow.updated", workflowSnapshot(c.state));
// Step 3: Emily — create roleplay session
const emilyModule = getSubAgentModule("emily");
const emilyMod = c.state.modules.find(m => m.id === "emily");
if (emilyMod && emilyModule?.service) {
emilyMod.status = "running";
appendTimelineEvent(c.state, emilyMod, "module", "Emily creating roleplay scenario...");
c.broadcast("workflow.updated", workflowSnapshot(c.state));
try {
const emilyResult = await runServiceAgentProbe(
{ id: emilyModule.id, name: emilyModule.name, role: emilyModule.role, kind: "microservice", description: emilyModule.description, service: emilyModule.service },
{ userId, goal: `Interview negotiation and communication practice for: ${goal}` },
);
emilyMod.lastResult = emilyResult;
emilyMod.status = emilyResult.status === "unavailable" ? "blocked" : "done";
appendTimelineEvent(c.state, emilyMod, "module", emilyResult.summary);
} catch (err) {
emilyMod.status = "blocked";
appendTimelineEvent(c.state, emilyMod, "module", `Emily session failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
c.broadcast("workflow.updated", workflowSnapshot(c.state));
// Step 4: Quinn — compute Q-Score
const quinnModule = getSubAgentModule("qscore");
const quinnMod = c.state.modules.find(m => m.id === "qscore");
if (quinnMod && quinnModule?.service) {
quinnMod.status = "running";
appendTimelineEvent(c.state, quinnMod, "module", "Quinn computing your readiness Q-Score...");
c.broadcast("workflow.updated", workflowSnapshot(c.state));
try {
const quinnResult = await runServiceAgentProbe(
{ id: quinnModule.id, name: quinnModule.name, role: quinnModule.role, kind: "score", description: quinnModule.description, service: quinnModule.service },
{ userId, goal },
);
quinnMod.lastResult = quinnResult;
quinnMod.status = quinnResult.status === "unavailable" ? "blocked" : "done";
appendTimelineEvent(c.state, quinnMod, "module", quinnResult.summary);
} catch (err) {
quinnMod.status = "blocked";
appendTimelineEvent(c.state, quinnMod, "module", `Q-Score computation failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
c.broadcast("workflow.updated", workflowSnapshot(c.state));
const doneCount = c.state.modules.filter(m => m.status === "done").length;
const totalModules = c.state.modules.filter(m => m.service).length;
const blockedCount = c.state.modules.filter(m => m.status === "blocked").length;
return {
ok: true,
workflowId: c.state.workflowId,
goal,
modulesCompleted: doneCount,
totalServiceModules: totalModules,
blocked: blockedCount,
summary: `Interview-to-Offer workflow completed ${doneCount}/${totalModules} service modules${blockedCount > 0 ? ` (${blockedCount} unavailable)` : ""}.`,
timeline: c.state.timeline,
modules: c.state.modules.filter(m => m.service).map(m => ({
id: m.id,
name: m.name,
status: m.status,
summary: m.lastResult?.summary ?? m.summary,
})),
};
}
default: {
// Check if this is a sub-agent capability tool from the catalog (changes.md §2D).
// These tools are loaded at build time — each sub-agent module defines its own tool names.

View File

@@ -54,6 +54,8 @@ export const config = {
process.env.ROLEPLAY_SERVICE_URL ?? "http://localhost:8008",
qscoreServiceUrl:
process.env.QSCORE_SERVICE_URL ?? "http://localhost:8000",
resumeServiceUrl:
process.env.RESUME_SERVICE_URL ?? "http://localhost:8002",
// ── Central Gitea (one org-wide instance, changes.md §2A) ──
giteaUrl: process.env.GITEA_URL ?? "http://127.0.0.1:3001",

View File

@@ -11,6 +11,7 @@ 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 { chatRoutes } from "./routes/chat.js";
import { db } from "./db/client.js";
import { hydratePortAllocator, reconcileOnBoot, ensureCentralGiteaReady } from "./docker/manager.js";
import { initCatalog } from "./agents/catalog.js";
@@ -68,9 +69,6 @@ async function main() {
}
});
// Rivet Kit actor traffic (frontend uses @rivetkit/react against this prefix).
app.all("/api/rivet/*", (c) => registry.handler(c.req.raw));
// HTTP control plane (auth-gated).
app.route("/users", userRoutes());
app.route("/agents", agentRoutes());
@@ -78,11 +76,57 @@ async function main() {
app.route("/actors", actorRoutes());
app.route("/opencode", opencodeRoutes());
app.route("/git", gitRoutes());
app.route("/api/chat", chatRoutes());
if (process.env.RIVET_RUN_ENGINE === "1") {
// Self-hosted: embedded engine runs at localhost:6420.
// Proxy frontend Rivet traffic to the engine instead of using registry.handler()
// (handler conflicts with startRunner — they're mutually exclusive).
delete process.env.RIVET_ENDPOINT;
app.all("/api/rivet/*", async (c) => {
const url = new URL(c.req.url);
url.hostname = "127.0.0.1";
url.port = "6420";
url.pathname = url.pathname.replace("/api/rivet", "");
// Forward headers, stripping hop-by-hop ones
const fwdHeaders = new Headers();
for (const [k, v] of Object.entries(c.req.raw.headers)) {
if (k.toLowerCase() === "host") continue;
if (k.toLowerCase() === "transfer-encoding") continue;
fwdHeaders.set(k, v);
}
fwdHeaders.set("Host", "127.0.0.1:6420");
// For POST/PUT/PATCH, clone the body stream (Hono may have consumed it)
const method = c.req.method.toUpperCase();
const bodyMethods = ["POST", "PUT", "PATCH", "DELETE"];
try {
const rawBody = bodyMethods.includes(method)
? await c.req.raw.clone().arrayBuffer()
: undefined;
const res = await fetch(url.toString(), {
method,
headers: fwdHeaders,
body: rawBody && rawBody.byteLength > 0 ? new Uint8Array(rawBody) : undefined,
});
return new Response(res.body, {
status: res.status,
headers: res.headers,
});
} catch (err) {
log.error({ err, url: url.toString() }, "rivet proxy error");
return c.json({ error: "proxy_error" }, 502);
}
});
registry.startRunner();
} else {
// Serverless: use registry.handler() for incoming actor traffic.
app.all("/api/rivet/*", (c) => registry.handler(c.req.raw));
}
registry.startRunner();
serve({ fetch: app.fetch, port: config.port }, (info) => {
log.info(

View File

@@ -9,7 +9,7 @@ export type SubAgentModule = {
name: string;
role: string;
description: string;
service?: "interview-service" | "roleplay-service" | "qscore-service";
service?: "interview-service" | "roleplay-service" | "qscore-service" | "resume-service";
toolNames: string[];
};
@@ -121,7 +121,8 @@ export async function loadPromptsFromDisk(): Promise<void> {
service &&
service !== "interview-service" &&
service !== "roleplay-service" &&
service !== "qscore-service"
service !== "qscore-service" &&
service !== "resume-service"
) {
log.warn({ file: filename, service }, "unknown service value — treating as no service");
}
@@ -132,7 +133,7 @@ export async function loadPromptsFromDisk(): Promise<void> {
role: data.role ?? data.name,
description: body || `Agent module: ${data.name}`,
service: service &&
["interview-service", "roleplay-service", "qscore-service"].includes(service)
["interview-service", "roleplay-service", "qscore-service", "resume-service"].includes(service)
? (service as SubAgentModule["service"])
: undefined,
toolNames: data.tools ?? [],

253
src/routes/chat.ts Normal file
View File

@@ -0,0 +1,253 @@
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";
import type { LlmMessage } from "../lib/llm.js";
import { createChatCompletion } from "../lib/llm.js";
import { buildUnifiedSystemPrompt } from "../agents/catalog.js";
import {
runServiceAgentProbe,
type ServiceAgentResult,
} from "../services/service-agents.js";
import { getSubAgentModules } from "../lib/prompt-loader.js";
const chatSchema = z.object({
messages: z.array(
z.object({
role: z.enum(["user", "assistant", "system"]),
content: z.string(),
}),
),
agentId: z.string().optional(),
});
function extractWorkflowTag(reply: string): string | undefined {
const match = reply.match(/\[WORKFLOW:\s*([a-z-]+)\]/i);
if (!match || !match[1]) return undefined;
return match[1].toLowerCase();
}
function cleanWorkflowTag(reply: string): string {
return reply.replace(/\[WORKFLOW:\s*[a-z-]+\]/gi, "").trim();
}
function buildTools() {
return [
{
type: "function" as const,
function: {
name: "start_interview_session",
description: "Create a real interview practice session via the Sara / interview-service microservice. Call this when the user asks to start or launch an interview.",
parameters: {
type: "object",
properties: {
target_role: { type: "string", description: "The target role and company, e.g., 'Software Engineer at Google'" },
},
required: ["target_role"],
},
},
},
{
type: "function" as const,
function: {
name: "start_roleplay_session",
description: "Create a real roleplay session via Emily / roleplay-service. Call when user asks for roleplay or negotiation practice.",
parameters: {
type: "object",
properties: {
goal: { type: "string", description: "What scenario to practice" },
},
required: ["goal"],
},
},
},
{
type: "function" as const,
function: {
name: "analyze_resume",
description: "Analyze user's resume using the Resume Agent. Returns completeness, skills, and gaps.",
parameters: {
type: "object",
properties: {
goal: { type: "string", description: "Target role for context" },
},
required: ["goal"],
},
},
},
{
type: "function" as const,
function: {
name: "compute_qscore",
description: "Compute user's readiness Q-Score via Quinn / qscore-service.",
parameters: {
type: "object",
properties: {},
required: [],
},
},
},
];
}
export function chatRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
app.post("/", async (c) => {
const userId = c.get("userId");
const body = chatSchema.parse(await c.req.json());
const userText = body.messages[body.messages.length - 1]?.content ?? "";
// 1. Try Rivet actor path (full tool suite + conversation history)
try {
const client = createClient<Registry>(config.rivetClientEndpoint);
const handle = client.userActor.getOrCreate([userId]);
await handle.init({ userId });
const result = await handle.receiveMessage({ text: userText });
if (result?.reply) {
const reply = cleanWorkflowTag(String(result.reply));
const workflow = extractWorkflowTag(String(result.reply));
return c.json({ reply, workflow, sessions: (result as any).sessions ?? [] });
}
} catch (err) {
console.warn("Rivet chat unavailable, using direct LLM:", err instanceof Error ? err.message : String(err));
}
// 2. Fallback: direct LLM with tool dispatch
const systemPrompt = buildUnifiedSystemPrompt();
const conversation: LlmMessage[] = [
{ role: "system", content: systemPrompt },
...body.messages.filter((m) => m.role !== "system"),
];
try {
const response1 = await createChatCompletion({
model: config.agentModel,
maxTokens: config.maxAgentTokens,
tools: buildTools(),
messages: conversation,
});
let reply = response1.content || "";
const sessions: Array<{
moduleId: string;
moduleName: string;
status: string;
sessionId?: string;
sessionUrl?: string;
summary?: string;
}> = [];
// If LLM called a tool, execute it
if (response1.toolCalls.length > 0) {
conversation.push({
role: "assistant",
content: response1.content,
tool_calls: response1.toolCalls.map((tc) => ({
id: tc.id,
type: "function" as const,
function: { name: tc.name, arguments: JSON.stringify(tc.arguments) },
})),
});
for (const toolCall of response1.toolCalls) {
console.log("LLM called tool:", toolCall.name, toolCall.arguments);
let toolResult: ServiceAgentResult;
switch (toolCall.name) {
case "start_interview_session": {
toolResult = await runServiceAgentProbe(
{ id: "sara", name: "Sara", role: "Interview Agent", kind: "microservice", description: "Interview practice", service: "interview-service" },
{ userId, goal: String(toolCall.arguments.target_role ?? "general preparation") },
);
if (toolResult.status === "ok" && toolResult.detail) {
const detail = toolResult.detail as Record<string, unknown>;
sessions.push({
moduleId: "sara",
moduleName: "Sara",
status: "done",
sessionId: detail.session_id as string,
sessionUrl: `http://localhost:8007/api/v1/demo?session_id=${detail.session_id ?? ""}`,
summary: toolResult.summary,
});
}
break;
}
case "start_roleplay_session": {
toolResult = await runServiceAgentProbe(
{ id: "emily", name: "Emily", role: "Roleplay Agent", kind: "microservice", description: "Roleplay practice", service: "roleplay-service" },
{ userId, goal: String(toolCall.arguments.goal ?? "general practice") },
);
if (toolResult.status === "ok" && toolResult.detail) {
const detail = toolResult.detail as Record<string, unknown>;
sessions.push({
moduleId: "emily",
moduleName: "Emily",
status: "done",
sessionId: detail.session_id as string,
sessionUrl: `http://localhost:8008/api/v1/demo?session_id=${detail.session_id ?? ""}`,
summary: toolResult.summary,
});
}
break;
}
case "analyze_resume": {
toolResult = await runServiceAgentProbe(
{ id: "resume", name: "Resume Agent", role: "Resume Builder", kind: "microservice", description: "Resume analysis", service: "resume-service" },
{ userId, goal: String(toolCall.arguments.goal ?? "general") },
);
if (toolResult.status === "ok") {
sessions.push({ moduleId: "resume", moduleName: "Resume Agent", status: "done", summary: toolResult.summary });
}
break;
}
case "compute_qscore": {
toolResult = await runServiceAgentProbe(
{ id: "qscore", name: "Quinn", role: "Q-Score Agent", kind: "score", description: "Readiness scoring", service: "qscore-service" },
{ userId, goal: "general assessment" },
);
if (toolResult.status === "ok") {
sessions.push({ moduleId: "qscore", moduleName: "Quinn", status: "done", summary: toolResult.summary });
}
break;
}
}
}
// Second LLM call: summarize tool results
const toolResults = sessions.map((s) =>
`Tool result: ${s.moduleName} - ${s.status} - ${s.summary || ""}${s.sessionUrl ? ` - Demo URL: ${s.sessionUrl}` : ""}`,
);
for (const tr of toolResults) {
conversation.push({ role: "tool", content: tr, tool_call_id: "tool" });
}
const response2 = await createChatCompletion({
model: config.agentModel,
maxTokens: 1024,
tools: [],
messages: conversation,
});
reply = cleanWorkflowTag(response2.content || reply);
}
return c.json({
reply: cleanWorkflowTag(reply),
workflow: extractWorkflowTag(reply),
sessions,
});
} catch (llmErr) {
console.error("Direct LLM chat error:", llmErr);
return c.json(
{ error: llmErr instanceof Error ? llmErr.message : "LLM error" },
{ status: 502 },
);
}
});
return app;
}

View File

@@ -170,21 +170,29 @@ async function runQuinnQScore(ctx: ServiceAgentContext): Promise<ServiceAgentRes
},
];
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,
}),
},
);
// Try to ingest signals (non-critical — may fail if QScore worker is down)
let ingest: Record<string, unknown> | undefined;
try {
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,
}),
},
);
} catch (err) {
// Signal ingestion is optional — compute may still work with cached signals
ingest = { status: "skipped", reason: err instanceof Error ? err.message : String(err) };
}
// Try to compute Q-Score
let compute: Record<string, unknown> | undefined;
try {
compute = await serviceJson<Record<string, unknown>>(
@@ -199,12 +207,18 @@ async function runQuinnQScore(ctx: ServiceAgentContext): Promise<ServiceAgentRes
},
);
} catch (err) {
// Graceful fallback: formula store unavailable → use static estimate
const avgSignalScore = Math.round(
signals.reduce((sum, s) => sum + s.score, 0) / signals.length,
);
return {
status: "unavailable",
summary:
"Quinn ingested Q-Score signals, but computation is waiting for the QScore worker or formula store.",
status: "ok",
summary: `Quinn estimated Q-Score ~${avgSignalScore} (service compute unavailable: formula store may not be seeded). Based on ${signals.length} signals.`,
detail: {
ingest,
estimated_q_score: avgSignalScore,
signal_scores: signals.map(s => ({ id: s.signal_id, score: s.score })),
compute_fallback: true,
compute_error: err instanceof Error ? err.message : String(err),
},
};
@@ -217,6 +231,64 @@ async function runQuinnQScore(ctx: ServiceAgentContext): Promise<ServiceAgentRes
};
}
// ── Resume Agent (resume-builder service from growqr-app) ──
async function runResumeAnalyze(ctx: ServiceAgentContext): Promise<ServiceAgentResult> {
// Probe resume state for the user
try {
const detail = await serviceJson<Record<string, unknown>>(
config.resumeServiceUrl,
`/api/state/${encodeURIComponent(ctx.userId)}`,
{ method: "GET" },
);
const completeness = detail.resume_completeness ?? 0;
const hasResume = (detail.resume_count as number) > 0;
return {
status: "ok",
summary: hasResume
? `Resume Agent found ${detail.resume_count} resume(s) at ${completeness}% completeness. Current role: ${detail.current_role ?? "unknown"}.`
: "No existing resume found. Resume Agent is ready to build one from scratch.",
detail: {
resume_count: detail.resume_count,
completeness,
current_role: detail.current_role,
current_company: detail.current_company,
skills: detail.technical_skills ?? detail.skills ?? [],
},
};
} catch (err) {
return {
status: "unavailable",
summary: `Resume Agent unavailable: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
async function runResumeTailor(ctx: ServiceAgentContext): Promise<ServiceAgentResult> {
// For now, return analysis-based tailoring
// The resume-builder's AI capabilities will handle actual tailoring
try {
const stateResult = await runResumeAnalyze(ctx);
if (stateResult.status !== "ok") return stateResult;
// Return summary with optimization guidance
return {
status: "ok",
summary: `Resume Agent analyzed your profile for the role "${ctx.goal}". Skills detected: ${(stateResult.detail as any)?.skills?.slice(0, 5).join(", ") ?? "none"}. Resume ready for optimization.`,
detail: {
...(stateResult.detail as Record<string, unknown> ?? {}),
goal: ctx.goal,
recommendation: "Use the AI analysis and copilot tools to tailor bullet points, add missing keywords, and optimize for ATS.",
},
};
} catch (err) {
return {
status: "unavailable",
summary: `Resume tailoring failed: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
export async function runServiceAgentProbe(
agent: ServiceAgentRef,
ctx?: ServiceAgentContext,
@@ -235,6 +307,10 @@ export async function runServiceAgentProbe(
return ctx
? await runQuinnQScore(ctx)
: healthCheck(config.qscoreServiceUrl, "Quinn / qscore-service");
case "resume-service":
return ctx
? await runResumeTailor(ctx)
: healthCheck(config.resumeServiceUrl, "Resume Agent / resume-service");
default:
return {
status: "local",