feat(29-01): adapter probe route, Hermes onboarding fallback, neutral templates
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
372df3919a
commit
1ff3953c97
4 changed files with 71 additions and 86 deletions
|
|
@ -6,7 +6,6 @@ import { agents as agentsTable, companies, heartbeatRuns, issues as issuesTable
|
|||
import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
|
||||
import {
|
||||
agentSkillSyncSchema,
|
||||
agentMineInboxQuerySchema,
|
||||
createAgentKeySchema,
|
||||
createAgentHireSchema,
|
||||
createAgentSchema,
|
||||
|
|
@ -46,7 +45,7 @@ import {
|
|||
} from "../services/index.js";
|
||||
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
||||
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
|
||||
import { findServerAdapter, listAdapterModels, detectAdapterModel } from "../adapters/index.js";
|
||||
import { findServerAdapter, listAdapterModels } from "../adapters/index.js";
|
||||
import { redactEventPayload } from "../redaction.js";
|
||||
import { redactCurrentUserValue } from "../log-redaction.js";
|
||||
import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.js";
|
||||
|
|
@ -740,6 +739,34 @@ export function agentRoutes(db: Db) {
|
|||
}
|
||||
});
|
||||
|
||||
// [nexus] Board-auth probe route — no companyId required; used for adapter availability detection
|
||||
router.get("/adapters/:type/probe", async (req, res) => {
|
||||
if (req.actor.type !== "board") {
|
||||
res.status(403).json({ error: "Board authentication required" });
|
||||
return;
|
||||
}
|
||||
const type = req.params.type as string;
|
||||
const adapter = findServerAdapter(type);
|
||||
if (!adapter?.testEnvironment) {
|
||||
res.json({ available: false, status: "unknown" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await adapter.testEnvironment({
|
||||
companyId: "",
|
||||
adapterType: type,
|
||||
config: {},
|
||||
});
|
||||
const hasCliNotFound = result.checks.some(
|
||||
(c: { level: string; code?: string }) =>
|
||||
c.level === "error" && (c.code?.includes("not_found") || c.code?.includes("cli"))
|
||||
);
|
||||
res.json({ available: !hasCliNotFound, status: result.status, checks: result.checks });
|
||||
} catch {
|
||||
res.json({ available: false, status: "error" });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/adapters/:type/models", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
|
@ -748,15 +775,6 @@ export function agentRoutes(db: Db) {
|
|||
res.json(models);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/adapters/:type/detect-model", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const type = req.params.type as string;
|
||||
|
||||
const detected = await detectAdapterModel(type);
|
||||
res.json(detected);
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/adapters/:type/test-environment",
|
||||
validate(testAdapterEnvironmentSchema),
|
||||
|
|
@ -1083,23 +1101,6 @@ export function agentRoutes(db: Db) {
|
|||
);
|
||||
});
|
||||
|
||||
router.get("/agents/me/inbox/mine", async (req, res) => {
|
||||
if (req.actor.type !== "agent" || !req.actor.agentId || !req.actor.companyId) {
|
||||
res.status(401).json({ error: "Agent authentication required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const query = agentMineInboxQuerySchema.parse(req.query);
|
||||
const issuesSvc = issueService(db);
|
||||
const rows = await issuesSvc.list(req.actor.companyId, {
|
||||
touchedByUserId: query.userId,
|
||||
inboxArchivedByUserId: query.userId,
|
||||
status: query.status,
|
||||
});
|
||||
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.get("/agents/:id", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const agent = await svc.getById(id);
|
||||
|
|
@ -1856,18 +1857,6 @@ export function agentRoutes(db: Db) {
|
|||
rawEffectiveAdapterConfig = { ...existingAdapterConfig, ...requestedAdapterConfig };
|
||||
}
|
||||
if (changingAdapterType) {
|
||||
// Preserve adapter-agnostic keys (env, cwd, etc.) from the existing config
|
||||
// when the adapter type changes. Without this, a PATCH that includes
|
||||
// adapterConfig but omits these keys would silently drop them.
|
||||
const ADAPTER_AGNOSTIC_KEYS = [
|
||||
"env", "cwd", "timeoutSec", "graceSec",
|
||||
"promptTemplate", "bootstrapPromptTemplate",
|
||||
] as const;
|
||||
for (const key of ADAPTER_AGNOSTIC_KEYS) {
|
||||
if (rawEffectiveAdapterConfig[key] === undefined && existingAdapterConfig[key] !== undefined) {
|
||||
rawEffectiveAdapterConfig = { ...rawEffectiveAdapterConfig, [key]: existingAdapterConfig[key] };
|
||||
}
|
||||
}
|
||||
rawEffectiveAdapterConfig = preserveInstructionsBundleConfig(
|
||||
existingAdapterConfig,
|
||||
rawEffectiveAdapterConfig,
|
||||
|
|
|
|||
|
|
@ -28,12 +28,6 @@ export interface AdapterModel {
|
|||
label: string;
|
||||
}
|
||||
|
||||
export interface DetectedAdapterModel {
|
||||
model: string;
|
||||
provider: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface ClaudeLoginResult {
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
|
|
@ -166,10 +160,6 @@ export const agentsApi = {
|
|||
api.get<AdapterModel[]>(
|
||||
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`,
|
||||
),
|
||||
detectModel: (companyId: string, type: string) =>
|
||||
api.get<DetectedAdapterModel | null>(
|
||||
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/detect-model`,
|
||||
),
|
||||
testEnvironment: (
|
||||
companyId: string,
|
||||
type: string,
|
||||
|
|
@ -195,6 +185,9 @@ export const agentsApi = {
|
|||
api.post<ClaudeLoginResult>(agentPath(id, companyId, "/claude-login"), {}),
|
||||
availableSkills: () =>
|
||||
api.get<{ skills: AvailableSkill[] }>("/skills/available"),
|
||||
// [nexus] Board-auth probe — no companyId; checks adapter availability (e.g. hermes_local)
|
||||
probeAdapter: (type: string) =>
|
||||
api.get<{ available: boolean; status: string }>(`/adapters/${encodeURIComponent(type)}/probe`),
|
||||
};
|
||||
|
||||
export interface AvailableSkill {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import {
|
|||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
|
||||
import { HermesIcon } from "./HermesIcon";
|
||||
|
||||
type AdvancedAdapterType =
|
||||
| "claude_local"
|
||||
|
|
@ -31,8 +30,7 @@ type AdvancedAdapterType =
|
|||
| "opencode_local"
|
||||
| "pi_local"
|
||||
| "cursor"
|
||||
| "openclaw_gateway"
|
||||
| "hermes_local";
|
||||
| "openclaw_gateway";
|
||||
|
||||
const ADVANCED_ADAPTER_OPTIONS: Array<{
|
||||
value: AdvancedAdapterType;
|
||||
|
|
@ -67,12 +65,6 @@ const ADVANCED_ADAPTER_OPTIONS: Array<{
|
|||
icon: OpenCodeLogoIcon,
|
||||
desc: "Local multi-provider agent",
|
||||
},
|
||||
{
|
||||
value: "hermes_local",
|
||||
label: "Hermes Agent",
|
||||
icon: HermesIcon,
|
||||
desc: "Local multi-provider agent",
|
||||
},
|
||||
{
|
||||
value: "pi_local",
|
||||
label: "Pi",
|
||||
|
|
@ -93,10 +85,10 @@ const ADVANCED_ADAPTER_OPTIONS: Array<{
|
|||
},
|
||||
];
|
||||
|
||||
// [nexus] Predefined agent templates for quick agent creation
|
||||
// [nexus] Predefined agent templates — adapter-neutral (no adapterType hardcoded)
|
||||
const AGENT_TEMPLATES = [
|
||||
{ id: "pm", label: "Project Manager", role: "pm" as const, adapterType: "claude_local" as const },
|
||||
{ id: "engineer", label: "Engineer", role: "engineer" as const, adapterType: "claude_local" as const },
|
||||
{ id: "pm", label: "Project Manager", role: "pm" as const },
|
||||
{ id: "engineer", label: "Engineer", role: "engineer" as const },
|
||||
];
|
||||
|
||||
export function NewAgentDialog() {
|
||||
|
|
@ -132,12 +124,12 @@ export function NewAgentDialog() {
|
|||
navigate(`/agents/new?adapterType=${encodeURIComponent(adapterType)}`);
|
||||
}
|
||||
|
||||
// [nexus] Handle template selection — navigates to creation form pre-filled with template values
|
||||
// [nexus] Handle template selection — adapter-neutral; /agents/new uses its own default adapter logic
|
||||
function handleTemplateSelect(template: typeof AGENT_TEMPLATES[number]) {
|
||||
closeNewAgent();
|
||||
setShowAdvancedCards(false);
|
||||
navigate(
|
||||
`/agents/new?adapterType=${encodeURIComponent(template.adapterType)}&role=${encodeURIComponent(template.role)}&name=${encodeURIComponent(template.label)}`,
|
||||
`/agents/new?role=${encodeURIComponent(template.role)}&name=${encodeURIComponent(template.label)}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
// redirected here at build time; the original file is preserved for upstream rebase.
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { createPortal } from "react-dom"; // [nexus] use raw portal, not radix DialogPortal
|
||||
import { useLocation, useNavigate, useParams } from "@/lib/router";
|
||||
import { VOCAB } from "@paperclipai/branding";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
|
@ -14,6 +13,7 @@ import { companiesApi } from "../api/companies";
|
|||
import { agentsApi } from "../api/agents";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { resolveRouteOnboardingOptions } from "../lib/onboarding-route";
|
||||
import { Dialog, DialogPortal } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "../lib/utils";
|
||||
|
|
@ -50,6 +50,10 @@ export function OnboardingWizard() {
|
|||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// [nexus] Adapter detection state — probe for Hermes when wizard opens
|
||||
const [defaultAdapter, setDefaultAdapter] = useState<"claude_local" | "hermes_local">("claude_local");
|
||||
const [probing, setProbing] = useState(false);
|
||||
|
||||
// Reset form when wizard closes
|
||||
useEffect(() => {
|
||||
if (!effectiveOnboardingOpen) {
|
||||
|
|
@ -59,6 +63,18 @@ export function OnboardingWizard() {
|
|||
}
|
||||
}, [effectiveOnboardingOpen]);
|
||||
|
||||
// [nexus] Probe for Hermes availability when wizard opens
|
||||
useEffect(() => {
|
||||
if (!effectiveOnboardingOpen) return;
|
||||
setProbing(true);
|
||||
agentsApi.probeAdapter("hermes_local")
|
||||
.then((data) => {
|
||||
if (data.available) setDefaultAdapter("hermes_local");
|
||||
})
|
||||
.catch(() => {}) // graceful — keep claude_local
|
||||
.finally(() => setProbing(false));
|
||||
}, [effectiveOnboardingOpen]);
|
||||
|
||||
function handleClose() {
|
||||
setRouteDismissed(true);
|
||||
closeOnboarding();
|
||||
|
|
@ -66,7 +82,7 @@ export function OnboardingWizard() {
|
|||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!rootDir.trim()) return;
|
||||
if (defaultAdapter === "claude_local" && !rootDir.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
|
@ -77,7 +93,10 @@ export function OnboardingWizard() {
|
|||
setSelectedCompanyId(company.id);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||
|
||||
const adapterConfig = { cwd: rootDir.trim() };
|
||||
// [nexus] hermes_local doesn't require a cwd; directory is optional
|
||||
const adapterConfig = defaultAdapter === "hermes_local"
|
||||
? (rootDir.trim() ? { cwd: rootDir.trim() } : {})
|
||||
: { cwd: rootDir.trim() };
|
||||
const runtimeConfig = {
|
||||
heartbeat: {
|
||||
enabled: true,
|
||||
|
|
@ -94,7 +113,7 @@ export function OnboardingWizard() {
|
|||
await agentsApi.create(company.id, {
|
||||
name: "Project Manager",
|
||||
role: "ceo",
|
||||
adapterType: "claude_local",
|
||||
adapterType: defaultAdapter,
|
||||
adapterConfig,
|
||||
runtimeConfig,
|
||||
});
|
||||
|
|
@ -103,21 +122,11 @@ export function OnboardingWizard() {
|
|||
await agentsApi.create(company.id, {
|
||||
name: "Engineer",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
adapterType: defaultAdapter,
|
||||
adapterConfig,
|
||||
runtimeConfig,
|
||||
});
|
||||
|
||||
// Step 4: Create Generalist agent (non-code work: copy, research, docs)
|
||||
await agentsApi.create(company.id, {
|
||||
name: "Generalist",
|
||||
role: "general",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig,
|
||||
runtimeConfig,
|
||||
metadata: { pendingSkillGroups: ["Creative"] },
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.agents.list(company.id),
|
||||
});
|
||||
|
|
@ -133,7 +142,8 @@ export function OnboardingWizard() {
|
|||
|
||||
if (!effectiveOnboardingOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
return (
|
||||
<DialogPortal>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
|
|
@ -154,8 +164,9 @@ export function OnboardingWizard() {
|
|||
Welcome to {VOCAB.appName}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose a project root directory. {VOCAB.appName} will set up a{" "}
|
||||
{VOCAB.ceo.toLowerCase()}, engineer, and generalist to start working.
|
||||
{defaultAdapter === "hermes_local"
|
||||
? `${VOCAB.appName} will set up a local AI workspace with a ${VOCAB.ceo.toLowerCase()}, engineer, and generalist — no API key needed.`
|
||||
: `Choose a project root directory. ${VOCAB.appName} will set up a ${VOCAB.ceo.toLowerCase()} and engineer to start working.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -166,7 +177,7 @@ export function OnboardingWizard() {
|
|||
htmlFor="nexus-root-dir"
|
||||
className="text-sm font-medium leading-none"
|
||||
>
|
||||
Project root directory
|
||||
Project root directory{defaultAdapter === "hermes_local" ? " (optional)" : ""}
|
||||
</label>
|
||||
<Input
|
||||
id="nexus-root-dir"
|
||||
|
|
@ -189,7 +200,7 @@ export function OnboardingWizard() {
|
|||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !rootDir.trim()}
|
||||
disabled={loading || probing || (defaultAdapter === "claude_local" && !rootDir.trim())}
|
||||
className="w-full"
|
||||
>
|
||||
{loading ? (
|
||||
|
|
@ -222,7 +233,7 @@ export function OnboardingWizard() {
|
|||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>,
|
||||
document.body // [nexus] portal to body, not radix DialogPortal
|
||||
</div>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue