feat: Talent Market + per-user onboarding + tenant default model#471
feat: Talent Market + per-user onboarding + tenant default model#471cinderzhan wants to merge 5 commits intomainfrom
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a1f448ce78
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| } | ||
| return r.json(); | ||
| }); | ||
| }).then(r => r.json()); |
There was a problem hiding this comment.
Restore non-2xx rejection in fetchAuth
fetchAuth now unconditionally returns r.json() and no longer throws on HTTP errors, so React Query treats failed requests as successes across AgentDetail. This can silently mask backend failures (mutations run onSuccess on 4xx/5xx) and, for list views like approvals, can pass error payloads where arrays are expected and trigger runtime errors when .filter is called.
Useful? React with 👍 / 👎.
| role_description: template.description || '', | ||
| template_id: template.id, | ||
| primary_model_id: modelId || undefined, | ||
| permission_access_level: 'manage', |
There was a problem hiding this comment.
Default new hires to use access, not manage
The post-hire flow always sends permission_access_level: 'manage', so choosing “Everyone at the company” grants manage access to all tenant members by default. Because access level is consumed by check_agent_access permission records, this over-privileges broad audiences compared with the wizard’s prior default (use) and can expose manage-only operations to unintended users.
Useful? React with 👍 / 👎.
| if _ovr and _ovr.enabled and _ovr.tenant_id and ( | ||
| not llm_model or _ovr.tenant_id == llm_model.tenant_id | ||
| ): |
There was a problem hiding this comment.
Scope model override to agent tenant, not current model
The override guard accepts any enabled tenant-scoped model when llm_model is missing because of the not llm_model branch. That means agents without an active base model can use arbitrary model UUIDs supplied by the client, bypassing tenant isolation for override selection; the check should be anchored to the agent/user tenant rather than whether a current model was loaded.
Useful? React with 👍 / 👎.
| default_heartbeat_interval = tenant.min_heartbeat_interval_minutes | ||
|
|
||
| # If the caller didn't pick a model, fall back to the tenant's default. | ||
| effective_primary_model_id = data.primary_model_id or tenant_default_model_id |
There was a problem hiding this comment.
Skip disabled tenant defaults when assigning primary model
Agent creation now blindly applies tenant.default_model_id when the caller omits primary_model_id, but defaults are not cleared when a model is later disabled. In that state, newly created agents inherit a disabled model and then fail chat model resolution (the websocket path drops disabled primaries), producing immediately unusable agents until manually reconfigured.
Useful? React with 👍 / 👎.
…Talent Market APIs
- New agent_user_onboardings junction table; a row exists per pair the user
has already been onboarded to, and is inserted as soon as the agent starts
streaming its first greeting chunk — the lock fires the instant the user
sees the agent respond.
- onboarding.py service:
* resolve_onboarding_prompt picks the right system prompt per turn:
founding (template.bootstrap_content) when this user is the first
to ever chat with the agent, welcoming (generic built-in) when
someone else was there first.
* mark_onboarded writes the junction row with ON CONFLICT DO NOTHING
semantics to survive concurrent first-turns.
- WebSocket handler prepends the resolved prompt, skips persistence for the
synthetic "kind=onboarding_trigger" turn the frontend fires, and flips the
junction row on the first chunk. Old file-based bootstrap.md flow removed
entirely (no disk write, no post-turn file-existence checks).
- Alembic migrations:
* add_agent_bootstrap_fields now only adds capability_bullets and
bootstrap_content to agent_templates (the short-lived
Agent.bootstrapped flag is no longer part of the design).
* add_agent_user_onboardings creates the junction table, backfills rows
for every (agent, user) pair with chat history so established
relationships never re-onboard, and drops Agent.bootstrapped.
- AgentTemplate gets bootstrap_content + capability_bullets; seeder authors
founding rituals for the 4 built-in templates.
- Tenant.default_model_id auto-sets on first enabled model added; set-default
endpoint lets admins reassign; wizard and Talent Market direct-hire both
inherit the default when no primary_model_id is supplied.
- /tenants/me is readable by any authenticated member so the wizard and chat
model switcher can tag the current default.
- WebSocket accepts a per-message model_id override for session-scoped model
switching without persisting the choice.
- AgentOut gains onboarded_for_me (computed per-request from the junction)
in place of the deprecated bootstrapped flag.
…g kickoff, ModelSwitcher
- TalentMarketModal replaces the sidebar create-agent button with a grid of
built-in templates plus a dashed custom-agent card that routes to the
existing wizard.
- PostHireSettingsModal collects visibility (company/only me) + preferred
model before creating; 仅创建 vs 立即对话 actions; the latter lands on
/agents/:id#chat so users skip the status tab on first entry.
- Onboarding kickoff (AgentDetail + Chat): when agent.onboarded_for_me is
false and a new session is opened, fire a tagged {kind: "onboarding_trigger"}
message. Backend swallows the user turn and streams the assistant greeting
— the agent opens the conversation itself, no visible user bubble. One-shot
per (agent, session) via a ref set. Founding vs welcoming content is
decided server-side.
- ModelSwitcher: compact pill in the chat toolbar, docked right next to the
send button with a fixed gap via a margin-left:auto right-hugging group.
Value resets to agent.primary_model_id on every mount for session scope;
dropdown tags the tenant default with 默认.
- AgentCreate wizard preselects tenant.default_model_id in the model step.
- EnterpriseSettings LLM tab shows a 默认 badge on the current default and
a 设为默认 button on other enabled models.
- Sidebar rename: 新建数字员工 → 招聘新成员; opens the Talent Market.
- en.json + zh.json: talentMarket.*, nav.hire, postHire.* entries - Agent interface: onboarded_for_me replaces the deprecated bootstrapped - tenantApi.me() for the shared /tenants/me endpoint - enterpriseApi.setDefaultModel() for the admin set-default action
a1f448c to
cb98384
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cb9838460f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| result = await db.execute(select(LLMModel).where(LLMModel.id == model_id)) | ||
| model = result.scalar_one_or_none() |
There was a problem hiding this comment.
Enforce tenant ownership before setting default model
set_default_llm_model accepts any model_id and immediately updates that model's tenant record, but it never checks that an org_admin belongs to the same tenant as the target model. In practice, any org admin who learns another tenant's model UUID can change that tenant's default model, which is a cross-tenant authorization break; add a tenant-scope guard before writing tenant.default_model_id.
Useful? React with 👍 / 👎.
| if is_onboarding_trigger: | ||
| # Minimal placeholder so the LLM has a valid user turn to anchor | ||
| # its greeting. The onboarding system prompt is what actually | ||
| # drives the reply; this text is never shown or saved. | ||
| content = "Please begin the onboarding." |
There was a problem hiding this comment.
Bypass onboarding trigger for OpenClaw sessions
This onboarding branch rewrites the synthetic trigger into a real user content string for every agent type, but OpenClaw requests exit via the gateway path and never hit the streaming callback that calls mark_onboarded. That means new OpenClaw chats can repeatedly enqueue the placeholder text ("Please begin the onboarding.") on empty sessions while onboarded_for_me stays false, producing noisy fake turns and a looped onboarding kickoff.
Useful? React with 👍 / 👎.
| }); | ||
|
|
||
| const enabled = (models as Model[]).filter(m => m.enabled !== false); | ||
| const selected = enabled.find(m => m.id === value) || enabled[0] || null; |
There was a problem hiding this comment.
Keep selected model label consistent with sent model_id
The component displays enabled[0] when value is null or no longer present, but it does not propagate that fallback to the parent via onChange. As a result, Chat/AgentDetail can show a concrete model in the pill while outgoing messages still carry model_id: null (or a stale invalid id), so requests may run against the wrong model or fail as "no model configured" despite the UI indicating otherwise.
Useful? React with 👍 / 👎.
…erable
Post-review feedback: the previous prompt was only injected on the trigger
turn, so the agent followed instructions on turn 1 (greeted + asked one
question) but defaulted back to asking clarifying questions on turn 2. The
deliverable step never landed.
Rework the injection so both turns of the ritual are guarded:
- resolve_onboarding_prompt now returns an OnboardingInjection(prompt,
lock_on_first_chunk) with the real user-message count interpolated.
It keeps firing for a given (user, agent) pair until user_turns >= 1
AND streaming begins; the junction row is only committed after the
deliverable turn, so if the user disconnects mid-greeting they still
get the ritual on their next visit.
- All four built-in bootstrap_contents (PM, Designer, Product Intern,
Market Researcher) rewritten around a {user_turns} branch:
* user_turns == 0 → warm one-line greeting + ONE tight question;
stop. No scope/team/context/deadline follow-ups.
* user_turns >= 1 → whatever they named is the subject. DO NOT ask
clarifying questions. Produce a concrete first deliverable inline
tailored to the role: a one-page project snapshot for PM, a
quick-win audit for Designer, a competitive snapshot for the
intern, a landscape map for the researcher. Close with a single
clear next-step offer.
- The generic welcoming prompt (non-founders) follows the same two-turn
branch so every first-meeting feels consistent.
- Onboarding sessions now get titled "Onboarding" up front in the WS
trigger path — the subsequent auto-title logic only overrides titles
that start with "Session ", so this stays sticky across the flow.
- Fixed list_sessions filter that was hiding onboarding sessions from
the "我的会话" tab: it counted only user messages, but agent-initiated
sessions have none yet. Count total messages instead.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7660ef8073
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| # — we want the ritual to retry if the user disconnects before replying. | ||
| return OnboardingInjection( | ||
| prompt=prompt, | ||
| lock_on_first_chunk=user_turns >= 1, |
There was a problem hiding this comment.
Reserve founder flow before first greeting streams
This return path delays onboarding row creation until user_turns >= 1, but founder detection above is based on whether any onboarding row exists for the agent. In practice, if user A opens a new agent (gets greeting turn with user_turns == 0) and user B opens it before A replies, peer_count is still zero and user B is also treated as founder, so multiple users can receive the founder bootstrap prompt and seed conflicting “first-user” context.
Useful? React with 👍 / 👎.
| display_content: userMsg, | ||
| file_name: attachedFiles.map(f => f.name).join(', ') | ||
| file_name: attachedFiles.map(f => f.name).join(', '), | ||
| model_id: overrideModelId, |
There was a problem hiding this comment.
Drive image prompt format from override model
This send path now forwards model_id overrides, but attachment formatting still relies on supportsVision computed from agent.primary_model_id (not the selected override). If a user switches models per session, image handling can invert: non-vision primary + vision override still sends path-only text, while vision primary + non-vision override still emits [image_data:...] markers, causing wrong behavior for the chosen model.
Useful? React with 👍 / 👎.
…amed greeting
Three issues from post-push review:
1. Opening a new session with an agent that had already completed onboarding
re-triggered the ritual. Root cause: the frontend's agent query was
cached with onboarded_for_me=false from before the lock fired, so the
kickoff effect fired again on the fresh session; backend then accepted
the trigger and the "Please begin the onboarding" placeholder reached
the LLM, which dutifully started over.
Fix: WebSocket guards stale triggers — if the pair already has a
junction row, short-circuit before LLM and emit a {type: "onboarded"}
event. Frontend invalidates the cached agent query on that event (and
when the real lock fires on turn 2), so subsequent sessions observe
onboarded_for_me=true and skip the kickoff.
2. Greeting content was too terse — no name, no capability pitch, no
emphasis on key info.
Fix: prompts now greet the user by display_name, bold the agent name,
list 2–3 bolded capabilities, bold the single question, and bold
section headers + next-step phrases in the deliverable turn. The
welcoming prompt (non-founders) follows the same structure.
Templates now interpolate {user_name} alongside {name} and {user_turns}.
3. Session title now explicitly set to "Onboarding" on the trigger turn so
it's identifiable in the session list before the user has typed
anything (previously already shipped; kept intact here).
Backend, frontend, and prompts all land together so the lock round-trip
and the richer content surface in the same release.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 70e5cd27c9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (d.type === 'onboarded') { | ||
| queryClient.invalidateQueries({ queryKey: ['agent', agentId] }); | ||
| return; | ||
| } |
There was a problem hiding this comment.
Reset waiting state on
onboarded websocket events
When a stale onboarding_trigger is rejected, the backend sends only an onboarded event and no chunk/done. This handler returns immediately after invalidating queries, so the isWaiting state set before sending the trigger is never cleared, leaving the composer disabled/spinning until reconnect or refresh. This is reproducible when cached onboarded_for_me=false lags behind server state (e.g., multi-tab or stale query cache).
Useful? React with 👍 / 👎.
| const [overrideModelId, setOverrideModelId] = useState<string | null>(null); | ||
| useEffect(() => { | ||
| if (agent?.primary_model_id && overrideModelId === null) { | ||
| setOverrideModelId(agent.primary_model_id); |
There was a problem hiding this comment.
Store model override per chat session
The model override is kept in a single component-level state and initialized only once, but AgentDetail supports switching among multiple sessions. After selecting a model in one session, subsequent turns in other sessions inherit that model_id, so requests run with an unintended model instead of a session-local choice. To match the intended session-scoped behavior, the override should be keyed by activeSession.id (or reset on session switch).
Useful? React with 👍 / 👎.
Summary
Two linked user-facing features that reshape how employees are hired, met, and configured:
仅创建or立即对话actions.立即对话lands on#chatso users skip the status tab on first entry.ModelSwitcherpill docked next to the send button.And the feature that redesigned from scratch versus the v1 of this branch:
How the onboarding mechanics work
agent_user_onboardings(agent_id, user_id, onboarded_at)is the source of truth. Row present = user has been onboarded; no row = next chat turn will have a system prompt injected.agent.creator_id) — so admins hiring on behalf of a team don't blow the context-collection questions on themselves.workspace/bootstrap.mdactivation flow + the short-livedAgent.bootstrappedcolumn. Activation is pure DB + in-memory prompt injection now.Commits
feat(backend): per-(user, agent) onboarding + tenant default model + Talent Market APIs— models, migrations, onboarding service, API endpoints, WebSocket protocolfeat(frontend): Talent Market, post-hire settings, per-user onboarding kickoff, ModelSwitcher— new components + page wiring + ModelSwitcher docked to send buttonchore: i18n strings, Agent type, api client helpersData model changes (Alembic migrations)
add_agent_bootstrap_fields— addsAgentTemplate.bootstrap_contentandAgentTemplate.capability_bulletsadd_tenant_default_model— addsTenant.default_model_id, backfills each tenant to its earliest-enabled modeladd_agent_user_onboardings— creates the junction table, backfills pairs fromchat_messages, drops the obsoleteAgent.bootstrappedcolumnTest plan
招聘新成员opens the Talent Market; custom card routes to/agents/new立即对话creates the agent, lands on/agents/:id#chatwith the agent's greeting streaming in; no user bubble visible仅创建creates the agent without navigation; new agent appears in sidebarrole_descriptionauto-fills from template设为默认reassignsAgentCreatewizard preselects tenant default in the model stepModelSwitcherpill is docked to the send button with fixed gap regardless of textarea width; dropdown tags the tenant default; switching affects only the current session/tenants/mereadable by any authenticated member