Skip to content

feat: Talent Market + per-user onboarding + tenant default model#471

Open
cinderzhan wants to merge 5 commits intomainfrom
yutong/agent-templates
Open

feat: Talent Market + per-user onboarding + tenant default model#471
cinderzhan wants to merge 5 commits intomainfrom
yutong/agent-templates

Conversation

@cinderzhan
Copy link
Copy Markdown
Collaborator

@cinderzhan cinderzhan commented Apr 24, 2026

Summary

Two linked user-facing features that reshape how employees are hired, met, and configured:

  1. Talent Market — the sidebar's create-agent button now opens a grid modal of built-in templates (plus a dashed custom-agent card that falls back to the existing wizard). A PostHireSettingsModal collects visibility + preferred model, with 仅创建 or 立即对话 actions. 立即对话 lands on #chat so users skip the status tab on first entry.
  2. Tenant default LLM model — the first enabled model an admin adds becomes the company's default; new agents inherit it; chat users can override per-session via a compact ModelSwitcher pill docked next to the send button.

And the feature that redesigned from scratch versus the v1 of this branch:

  1. Per-(user, agent) onboarding — every unique pair gets one onboarding conversation. The first person to ever chat with an agent gets the founding flow (template-specific, collects project context, suggests a first task). Every subsequent user meets the agent via a welcoming flow (generic built-in, introduces the agent and asks how to help — no context re-collection). The lock fires as soon as the agent starts streaming its greeting, so the ritual never retriggers even if the user closes mid-flow.

How the onboarding mechanics work

  • Data model: 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.
  • Founder vs welcomer: the founder is defined as "the first user who opens chat with this agent" (not agent.creator_id) — so admins hiring on behalf of a team don't blow the context-collection questions on themselves.
  • Backfill: every existing (agent, user) pair with chat history is inserted into the junction table so established relationships never re-onboard.
  • Dropped in this rewrite: the old file-based workspace/bootstrap.md activation flow + the short-lived Agent.bootstrapped column. 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 protocol
  • feat(frontend): Talent Market, post-hire settings, per-user onboarding kickoff, ModelSwitcher — new components + page wiring + ModelSwitcher docked to send button
  • chore: i18n strings, Agent type, api client helpers

Data model changes (Alembic migrations)

  • add_agent_bootstrap_fields — adds AgentTemplate.bootstrap_content and AgentTemplate.capability_bullets
  • add_tenant_default_model — adds Tenant.default_model_id, backfills each tenant to its earliest-enabled model
  • add_agent_user_onboardings — creates the junction table, backfills pairs from chat_messages, drops the obsolete Agent.bootstrapped column

Test plan

  • Migrations apply cleanly on a fresh DB and on a DB with existing agents/tenants/chat history
  • Sidebar 招聘新成员 opens the Talent Market; custom card routes to /agents/new
  • 立即对话 creates the agent, lands on /agents/:id#chat with the agent's greeting streaming in; no user bubble visible
  • 仅创建 creates the agent without navigation; new agent appears in sidebar
  • Agent's role_description auto-fills from template
  • Founding flow: first user meeting a newly-hired template agent gets the template's context-collection greeting
  • Welcoming flow: second user meeting the same agent gets the short welcoming intro, no context questions
  • Lock semantics: user closes chat mid-greeting → next session does NOT re-trigger onboarding
  • Existing agents with prior chat history: user never gets onboarding (backfill worked)
  • Admin adding the first enabled model auto-sets it as tenant default; 设为默认 reassigns
  • AgentCreate wizard preselects tenant default in the model step
  • Chat ModelSwitcher pill 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/me readable by any authenticated member

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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());
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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',
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +374 to +376
if _ovr and _ovr.enabled and _ovr.tenant_id and (
not llm_model or _ovr.tenant_id == llm_model.tenant_id
):
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread backend/app/api/agents.py
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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
@cinderzhan cinderzhan force-pushed the yutong/agent-templates branch from a1f448c to cb98384 Compare April 24, 2026 06:12
@cinderzhan cinderzhan changed the title feat: Talent Market + bootstrap ritual + tenant default model feat: Talent Market + per-user onboarding + tenant default model Apr 24, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +199 to +200
result = await db.execute(select(LLMModel).where(LLMModel.id == model_id))
model = result.scalar_one_or_none()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +359 to +363
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."
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +2051 to +2054
if (d.type === 'onboarded') {
queryClient.invalidateQueries({ queryKey: ['agent', agentId] });
return;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +1348 to +1351
const [overrideModelId, setOverrideModelId] = useState<string | null>(null);
useEffect(() => {
if (agent?.primary_model_id && overrideModelId === null) {
setOverrideModelId(agent.primary_model_id);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant