Skip to main content

AI Agent System

The AI agent system provides configurable, RAG-powered conversational assistants within the AIQLick platform. Agents can answer questions using uploaded documents, execute tools to query the database and search talents/jobs, and stream responses in real time over GraphQL subscriptions. Agents run on the background-tasks service and support two ownership models: company-scoped (employer) and user-owned (job seeker).

Ownership Model

Agents support dual ownership to serve both company users and job seekers:

OwnercompanyIdcreatedByIdScopeUse Case
CompanySet (UUID)OptionalShared across company membersEmployer agents for recruitment, pipeline, etc.
UserNULLSet (UUID)Private to the userJob seeker personal assistant
  • Company agents are unique by (companyId, createdById, name) and visible to all company members.
  • User agents have companyId = NULL and are only visible to the creating user.
  • Idempotent creation: The createAgent mutation returns an existing agent if one with the same name already exists for the company/user, preventing duplicates from the frontend auto-create flow.
  • Access control: Company agents verify context.company_id matches. User agents verify context.user_id matches createdById.

Default Agent Provisioning

Every user is expected to land with at least one default agent so the chat panel can work on first open. Provisioning is now enforced natively by the backend via two mechanisms:

  1. Event Listeners: SystemAgentListener catches company.created and user.email.verified events to provision agents immediately during onboarding.
  2. Session Checks: The WhoAmI GraphQL resolver checks for the existence of system agents (both jobseeker-scoped and company-scoped) on every session load. If missing, it asynchronously provisions them in the background.

This backend-driven approach replaces the legacy frontend-side createDefaultAgent fallback, ensuring agents are reliably available regardless of how the user registered (e.g. OAuth) or whether they are an older user from before the agent feature existed.

TriggerCaller / LocationScope createdAgent typeDefault nameTool set
Company creationcompany.created eventCompany-scoped (companyId set)RECRUITMENT_ASSISTANT"AiQlick System Agent"Recruiter palette (38 tools)
User email verificationuser.email.verified eventUser-scoped (companyId = NULL)JOB_MATCHING"AiQlick System Agent"Job-seeker palette (19 tools)
Session Load (WhoAmI)UsersResolver.whoAmIBoth (as needed)RECRUITMENT_ASSISTANT / JOB_MATCHING"AiQlick System Agent"Respective palette

Idempotency (SUP-00016): The backend SystemAgentService.ensureForUser and ensureForCompany methods perform a Prisma.findFirst check (isSystem: true and respective scope) before creating an agent. A unique constraint on (companyId, createdById, name) prevents race conditions during concurrent provisioning attempts.

The ChatPanel path uses selectedAgentId which is only cleared when a stored localStorage.selectedChatAgentId fails validation against the loaded agents list. The frontend no longer attempts to auto-create agents; it simply waits for the backend provisioning to reflect in the listAgents query.

Page Context

When the frontend sends a pageContext key with each chat message, the system injects contextual guidance into the LLM system prompt. This helps the agent provide relevant suggestions based on what the user is currently looking at.

How It Works

  1. Frontend detects the current page from the URL pathname (e.g., /company/resume/talents/uuidtalent_detail)
  2. The pageContext and optional entityId are sent with each agentChat subscription message
  3. The conversation service appends a [Page Context] block to the system prompt
  4. Tools are auto-activated for the page: the agent's configured enabledTools is UNION-ed with the page's tool set at runtime (gated by an allowlist — see Page-Context Tool Activation), then the resulting list is priority-ordered so the most relevant tools are considered first

Context Envelope

Every agent turn is built against a structured context envelope (AgentContext) assembled once at the top of send_message_stream by context_builder.build_agent_context(). The envelope carries three blocks into both the system prompt and the tool service:

  • UserSnapshot — broad context about the caller: name, email, view mode, company, role, plan, billing mode, subscription status, credit balance, active CV. Assembled by joining UserCompanyUserCompanyRoleRoleSubscriptionPlanCreditBalance in one query.
  • EntitySnapshot — compact snapshot of the entity the user is currently looking at on a detail page (e.g. on talent_detail the Talent's name, title, email, location, active CV, pool, candidate count, note count). Fetched per-page by a dedicated async function.
  • AgentContext — combines both plus page_context, page_pathname, conversation_id, and agent_id.

Every fetcher is wrapped in a 500ms asyncio.wait_for() timeout. If a query fails or times out, the fetch degrades to None with a warning log — the conversation still runs with a thinner prompt rather than failing the whole turn.

Rendered system prompt shape:

You are a helpful AI assistant for the AIQLick recruitment platform.

[User]
Name: Reza Zeraat
Email: rezdev97@gmail.com
Role: EMPLOYER at AIQLick (Owner)
Plan: Enterprise (POSTPAID) [ACTIVE]
Credit balance: 1250
Active CV: none

[Page]
Page: talent_detail
Route: /company/resume/talents/abc-123-def-456

[Entity: Talent]
id: abc-123-def-456
name: Jane Smith
title: Senior Frontend Engineer
email: jane@example.com
location: Berlin, Germany
active_cv_id: cv-789-xyz-012
talent_pool: Engineering Q4 2025
candidate_count: 3
note_count: 5

[Instructions]
When the user says 'this talent' / 'them' / 'edit this', call tools like
update_talent, add_talent_note, add_candidate_to_job, … WITHOUT the
talent_id parameter — the system uses the talent from [Entity: Talent]
automatically.

Because the LLM sees the real entity UUID in [Entity], it no longer needs to invent placeholder strings like "TALENT-UUID" or "user_cv_id". Even when it does slip up, the tool handler still receives the envelope via self.context and can resolve the missing ID from the page — see Entity Fallback Resolution.

Entity Fallback Resolution

Write handlers for detail pages share a common three-step resolution pattern:

  1. Validate the UUID from params via _is_valid_uuid(). Non-UUID strings (LLM hallucinations like "CV-UUID-PLACEHOLDER") are treated as missing, not passed to asyncpg.
  2. Fall back to the page entity via _resolve_page_entity_id(expected_pages), which returns self.context.entity.entity_id only if the current page matches one of the expected contexts and the UUID is well-formed.
  3. Enforce authorization via an _assert_*_access helper (see Access Control Helpers) before any DB mutation. The fallback is never a trust vector — the access check is what actually authorizes the write.

Handlers covered by this pattern:

HandlerFallback pageAccess helper
_handle_read_cv / _handle_update_cvjs_resume, cv_builder (via self.user_id's active CV)_assert_cv_access
_handle_update_talenttalent_detail_assert_talent_access
_handle_update_candidate_statuscandidate_detail_assert_candidate_access
_handle_add_candidate_to_jobtalent_detail (talent) + job_detail (job)_assert_talent_access + _assert_job_access
_handle_add_talent_notetalent_detail_assert_talent_access
_handle_recommend_jobs_for_talenttalent_detail_assert_talent_access
_handle_get_talent_cvtalent_detail_assert_talent_access
_handle_get_match_scorestalent_detail / candidate_detail / job_detail_assert_talent_access + _assert_job_access
_handle_contact_talenttalent_detail_assert_talent_access
_handle_edit_jobjob_detail_assert_job_access(writable=True)
_handle_duplicate_jobjob_detail_assert_job_access(writable=True)
_handle_edit_candidatecandidate_detail_assert_candidate_access

Access Control Helpers

DB-backed authorization, single source of truth. All write handlers invoke the matching helper before any mutation — the fallback-resolved entity_id goes through the same check, so a caller who isn't allowed to touch the page entity is still denied.

HelperCompany agentJobseeker agent
_assert_cv_access(cv_id)CV belongs to a talent in the company pool OR to a user with a UserCompanyRole in the companyCV is owned by self.user_id
_assert_talent_access(talent_id)Talent's pool companyId matches self.company_idDenied (talents are recruiter-side)
_assert_candidate_access(candidate_id)Candidate's Job companyId matchesCandidate's userId matches self.user_id
_assert_job_access(job_id, writable=False)Job companyId matches OR caller has a UserCompanyRole in the job's companyRead-only: any existing job. Writable=True always denied.

Supported Contexts

Company pages:

Context KeyRoute PatternEntity
dashboard/company/dashboard--
pipeline/company/pipeline--
talents/company/resume/talents--
talent_detail/company/resume/talents/:idtalent UUID
talentpool/company/resume/talentpool--
candidates/company/resume/candidates--
candidate_detail/company/resume/candidates/:idcandidate UUID
jobs/company/jobs--
job_detail/company/jobs/:idjob UUID
interviews/company/interviews--
meetings/company/meetings--
team/company/team--
connections/company/connections--
sharelist/company/resume/sharelist--

Job seeker pages:

Context KeyRoute PatternEntity
js_dashboard/jobseeker/dashboard--
js_jobs/jobseeker/jobs--
js_job_detail/jobseeker/jobs/:idjob UUID
js_applications/jobseeker/application--
js_interviews/jobseeker/interviews--
js_interview_detail/jobseeker/interviews/:idinterview UUID
js_resume/jobseeker/resume--
js_connections/jobseeker/connections--

Shared pages:

Context KeyRoute PatternEntity
cv_builder/cvbuilder--

Page-Context Tool Activation

When pageContext is provided and no forcedTools are set, the conversation service auto-activates the page's tool set — it does NOT require the agent's DB config to already list those tools. This means a generic agent instantly becomes capable of CV editing on the CV builder page, candidate operations on the pipeline page, etc., without any per-page configuration.

Union semantics (conversation_service.py, _stream_chat_response):

effective_enabled_tools = list(config.enabled_tools or [])
if page_context in AgentToolService.TOOL_PRIORITY_BY_CONTEXT:
for t in AgentToolService.TOOL_PRIORITY_BY_CONTEXT[page_context]:
if t in effective_enabled_tools:
continue
if t not in AgentToolService.AUTO_ENABLE_FROM_CONTEXT:
continue # allowlist gate
effective_enabled_tools.append(t)

The resulting list is then passed to AgentToolService and priority-ordered (tools from TOOL_PRIORITY_BY_CONTEXT[page_context] appear before the rest in the LLM's tool_config).

AUTO_ENABLE_FROM_CONTEXT allowlist — only tool types on this set are eligible for auto-activation. Read-heavy tools, CV tools (caller-scoped via _assert_cv_access), common recruitment writes, self tools, and page-entity-scoped writes are allowed; destructive/sensitive operations must still be explicitly configured on the agent.

Current members: RAG_SEARCH, JOB_SEARCH, CANDIDATE_SEARCH, TALENT_SEARCH, COMPANY_INFO, PIPELINE_OVERVIEW, INTERVIEW_SCHEDULE, MEETING_LIST, GET_MEETING_INSIGHT, CV_READ, CV_UPDATE, CV_EXTRACTION, CV_ANALYSIS, GET_TALENT_CV_DETAILS, GET_MATCH_SCORES, HIRING_ANALYTICS, TALENT_POOL_INSIGHTS, RECOMMEND_TALENTS_FOR_JOB, RECOMMEND_JOBS_FOR_TALENT, JOB_PARSING, GENERATE_JOB_DESCRIPTION, CREATE_TALENT, UPDATE_TALENT, UPDATE_CANDIDATE_STATUS, ADD_TALENT_NOTE, ADD_CANDIDATE_TO_JOB, SHORTLIST_CANDIDATES, COMPARE_CANDIDATES, GET_ME, GET_MY_STATS, GET_MY_SUBSCRIPTION, GET_MY_RECENT_ACTIVITY, UPDATE_MY_PROFILE, CONTACT_TALENT, EDIT_JOB, DUPLICATE_JOB, EDIT_CANDIDATE.

Note: DATABASE_QUERY, voice operations, and any future privileged tool are intentionally excluded — they must be enabled on the agent explicitly.

Page → tool map (TOOL_PRIORITY_BY_CONTEXT in tool_service.py):

ContextAuto-Activated Tools
dashboardGET_ME, GET_MY_STATS, GET_MY_SUBSCRIPTION, COMPANY_INFO, PIPELINE_OVERVIEW, JOB_SEARCH, HIRING_ANALYTICS, TALENT_POOL_INSIGHTS
pipelinePIPELINE_OVERVIEW, CANDIDATE_SEARCH, UPDATE_CANDIDATE_STATUS, EDIT_CANDIDATE, GET_MATCH_SCORES, SHORTLIST_CANDIDATES, COMPARE_CANDIDATES
talentsTALENT_SEARCH, CREATE_TALENT, CV_EXTRACTION, UPDATE_TALENT, ADD_TALENT_NOTE, CONTACT_TALENT, RECOMMEND_JOBS_FOR_TALENT
talent_detailCV_READ, CV_UPDATE, GET_TALENT_CV_DETAILS, UPDATE_TALENT, ADD_TALENT_NOTE, CONTACT_TALENT, CV_EXTRACTION, CV_ANALYSIS, GET_MATCH_SCORES, RECOMMEND_JOBS_FOR_TALENT, ADD_CANDIDATE_TO_JOB, TALENT_SEARCH
talentpoolTALENT_SEARCH, TALENT_POOL_INSIGHTS, CREATE_TALENT
candidatesCANDIDATE_SEARCH, GET_MATCH_SCORES, UPDATE_CANDIDATE_STATUS, EDIT_CANDIDATE, SHORTLIST_CANDIDATES
candidate_detailCV_READ, CV_UPDATE, GET_TALENT_CV_DETAILS, UPDATE_CANDIDATE_STATUS, EDIT_CANDIDATE, GET_MATCH_SCORES, CV_ANALYSIS, ADD_TALENT_NOTE, CONTACT_TALENT, CANDIDATE_SEARCH
jobsJOB_SEARCH, JOB_PARSING, GENERATE_JOB_DESCRIPTION, RECOMMEND_TALENTS_FOR_JOB, DUPLICATE_JOB, EDIT_JOB
job_detailEDIT_JOB, DUPLICATE_JOB, CANDIDATE_SEARCH, RECOMMEND_TALENTS_FOR_JOB, GET_MATCH_SCORES, JOB_PARSING, GENERATE_JOB_DESCRIPTION, JOB_SEARCH
interviewsINTERVIEW_SCHEDULE
meetingsMEETING_LIST, GET_MEETING_INSIGHT
sharelistTALENT_SEARCH
teamCOMPANY_INFO, GET_ME
connectionsCOMPANY_INFO
aiRAG_SEARCH
js_dashboardGET_ME, GET_MY_STATS, GET_MY_SUBSCRIPTION, GET_MY_RECENT_ACTIVITY, UPDATE_MY_PROFILE, JOB_SEARCH, INTERVIEW_SCHEDULE
js_jobsJOB_SEARCH, GET_MATCH_SCORES
js_job_detailJOB_SEARCH, GET_MATCH_SCORES, JOB_PARSING
js_resumeCV_READ, CV_UPDATE, UPDATE_MY_PROFILE, CV_EXTRACTION, CV_ANALYSIS, RAG_SEARCH, GET_ME
cv_builderCV_READ, CV_UPDATE, UPDATE_MY_PROFILE, CV_EXTRACTION, CV_ANALYSIS, RAG_SEARCH, GET_ME
js_interviews / js_interview_detailINTERVIEW_SCHEDULE
js_applicationsJOB_SEARCH, INTERVIEW_SCHEDULE
Why this exists

Before this mechanism existed, the CV builder chat agent would refuse prompts like "change my email to rz@example.com" because its DB-configured enabledTools didn't list CV_UPDATE. Rather than manually seed every agent with the CV tools, the service now auto-activates the tools that make sense for wherever the user is standing in the UI. The allowlist + tool-level authorization (see Tool Security Hardening) keep the expansion safe.

ViewMode-Aware Behaviour

The job-seeker agent and the recruiter agent are different products that happen to share a row in the Agent table. The split happens at three layers in the backend, all of them gated on self.company_id (which is None for job seekers and a UUID for recruiters — derived from the request context, not the agent's stored type):

  1. Tool allowlistget_enabled_tools() filters the advertised tool list down to JOB_SEEKER_ALLOWED_TOOL_TYPES when the caller has no company context, so the LLM never even sees recruiter-only tools.
  2. Dispatch gateexecute_tool() refuses non-allowlisted tools with a targeted error from JOB_SEEKER_TOOL_REDIRECTS ("use search_jobs / recommend_jobs_for_talent instead") so an LLM that tries to call one anyway gets a recovery hint instead of a permission error deep in a handler.
  3. Persona prefix_render_system_prompt() prepends a ViewMode-specific persona block before the agent's stored system_prompt, so an agent created with a legacy recruiter prompt (e.g. a RECRUITMENT_ASSISTANT from before the split existed) automatically talks like a career advisor when the caller is a job seeker, without any data migration.

Job-seeker allowlist

The 15 tools job seekers are allowed to call (defined in tool_service.py as JOB_SEEKER_ALLOWED_TOOL_TYPES):

CategoryTools
Discovery & searchRAG_SEARCH, JOB_SEARCH, RECOMMEND_JOBS_FOR_TALENT, GET_MATCH_SCORES, COMPANY_INFO
CV self-serviceCV_READ, CV_UPDATE, CV_EXTRACTION, CV_ANALYSIS, GET_TALENT_CV_DETAILS
External parsingJOB_PARSING
Schedule & meetings (own)INTERVIEW_SCHEDULE, MEETING_LIST, GET_MEETING_INSIGHT
Self profileGET_ME, GET_MY_STATS, GET_MY_SUBSCRIPTION, GET_MY_RECENT_ACTIVITY, UPDATE_MY_PROFILE

Every tool in the allowlist has a job-seeker branch in its handler that scopes reads to the caller (by userId, by self-owned Talent row, or by MeetingAttendee membership) instead of by companyId:

HandlerJob-seeker scoping strategy
_handle_job_searchDrops the "companyId" = $1 filter; LEFT JOIN "UserJobMatchScore" on self.user_id and ORDER BY ms.score DESC NULLS LAST, j."createdAt" DESC so keyword results are ranked by personalised fit when scores exist
_handle_recommend_jobs_for_talenttalent_id no longer required — resolves the target directly from self.user_id, drops the company filter, and includes company_name + job_id in every result entry so the LLM can render "Title at Company"
_handle_interview_scheduleWHERE c."userId" = $1 — scopes to interviews where the caller is the candidate
_handle_get_match_scoresDrops the j."companyId" = $1 filter, scopes by ms."userId" = self.user_id, always filters out DRAFT/CLOSED jobs
_handle_company_infoNew company_id / company_name params let the caller look up any company; private stats (total_talents, total_candidates, total_interviews, team_members, member list) are suppressed for cross-company lookups — only public fields and active-job counts are returned
_handle_meeting_listJOIN "MeetingAttendee" ma ON ma."meetingId" = m.id WHERE ma."userId" = $1 — scopes to meetings the caller was invited to
_handle_get_meeting_insightmeeting_id / interview_id lookups join MeetingAttendee for attendance scoping; candidate_id is refused in job-seeker mode with "recruiter-only lookup" error
_assert_talent_accessSelf-ownership branch: WHERE id = $1 AND "userId" = $2 — a job seeker can reference their own Talent row but nobody else's

Recruiter tools (TALENT_SEARCH, CANDIDATE_SEARCH, PIPELINE_OVERVIEW, CREATE_TALENT, UPDATE_TALENT, UPDATE_CANDIDATE_STATUS, ADD_CANDIDATE_TO_JOB, ADD_TALENT_NOTE, RECOMMEND_TALENTS_FOR_JOB, HIRING_ANALYTICS, TALENT_POOL_INSIGHTS, SHORTLIST_CANDIDATES, COMPARE_CANDIDATES, CONTACT_TALENT, EDIT_JOB, DUPLICATE_JOB, EDIT_CANDIDATE, GENERATE_JOB_DESCRIPTION, DATABASE_QUERY) are not in the allowlist — each returns a specific "use X instead" redirect via JOB_SEEKER_TOOL_REDIRECTS when a job-seeker agent attempts them.

Persona prefix

AgentConversationService._render_system_prompt() prepends one of two class-level constants based on context.user.view_mode:

ViewModePrefix constantFraming
JOB_SEEKERPERSONA_PREFIX_JOB_SEEKER"You are the user's personal AI career assistant... help the user find jobs that match their skills, prepare for interviews, improve their CV, and track their applications. You do NOT have access to recruiter tools — do not attempt to call them. Your primary tools are: recommend_jobs_for_talent (no arguments), search_jobs, read_cv/update_cv, get_interviews, get_meetings, get_company_info, update_my_profile."
EMPLOYERPERSONA_PREFIX_EMPLOYER"You are an AI recruitment assistant... help the user manage their company's hiring pipeline. Prefer recruiter tools: search_talents, search_candidates, recommend_talents_for_job, get_pipeline_overview, hiring_analytics, etc."

The prefix lands before the agent's configured system_prompt in the rendered prompt, so if a job seeker is talking to an agent whose stored prompt says "You are a recruitment assistant", the persona prefix overrides that framing for the turn. This is what makes existing RECRUITMENT_ASSISTANT agents (created before the split) behave correctly for job seekers without a data migration.

Unit tests covering all three layers live in tests/unit/services/agent/test_job_seeker_branches.py (13 tests: dispatch gate, allowlist filter, self-ownership branch, persona prefix ordering).

Why this design instead of a second agent type

We already had a JOB_MATCHING value in the AgentType enum, and the frontend does now use it for new job-seeker agents (see Default Agent Provisioning). But the type field is metadata-only on the backend — the dispatch gate and persona prefix both branch on self.company_id / context.user.view_mode, not on agent.type. That's deliberate: it means existing agents (like "Reza JS Assistant" — a RECRUITMENT_ASSISTANT row with companyId = NULL from before the split) just start working correctly when the new backend ships, with no migration. The JOB_MATCHING type is purely cosmetic / descriptive.

Data Model

The agent system is backed by 12 interconnected database tables:

TablePurpose
AgentAgent configuration: name, LLM model, system prompt, RAG settings, enabled tools, rate limits. companyId is nullable (NULL for job seeker agents)
AgentToolDefinitionPersistent tool definitions with pgvector embeddings for Tool RAG (semantic tool selection)
AgentTaskConfigTask-specific overrides (system prompt, temperature, RAG filters, tools, guardrails)
AgentDatabaseConfigDatabase access permissions: allowed/denied tables, PII masking, query templates
AgentConversationConversation session between a user and an agent, with token tracking and feedback. companyId is nullable for job seeker conversations
AgentMessageIndividual messages (USER, ASSISTANT, SYSTEM, TOOL) with token metrics and RAG context. See Message role semantics for how TOOL rows relate to ASSISTANT toolCalls.
AgentDocumentDocuments uploaded to an agent's knowledge base with RAG inclusion control
AgentDocumentChunkText chunks with pgvector embeddings (1024 dimensions) for RAG retrieval
AgentToolExecutionExecution log of tool calls with input, output, duration, and SQL query details
AgentActivityAudit log for agent operations (27 activity types)
AgentBackgroundTaskBackground task queue for document processing and embedding generation
AgentTokenUsageToken consumption tracking per period for billing

Agent Configuration

FieldTypeDefaultDescription
nameString--Display name (unique per company)
typeAgentTypeRECRUITMENT_ASSISTANTRECRUITMENT_ASSISTANT, TALENT_SEARCH, JOB_MATCHING, INTERVIEW_PREP, DATA_ANALYST, CUSTOM (editable after creation)
statusAgentStatusACTIVEACTIVE, INACTIVE, MAINTENANCE, DEPRECATED
llmProviderStringbedrockLLM provider
llmModelStringeu.anthropic.claude-sonnet-4-6Bedrock model ID. Default is the cheapest reasoning-capable Claude — see AI Models for the supported set. Haiku/Nova IDs work but won't stream reasoning.
temperatureFloat0.7LLM temperature
maxTokensInt4096Max output tokens
systemPromptStringnullCustom system prompt (overridden by TaskConfig if set)
ragEnabledBooleanfalseEnable RAG document search
ragTopKInt5Number of RAG chunks to retrieve
ragMinScoreFloat0.15Minimum similarity score for RAG
embeddingModelStringamazon.titan-embed-text-v2:0Embedding model for RAG
embeddingDimensionInt1024Embedding vector dimensions
enabledToolsAgentToolType[][]Enabled tool types
maxContextMessagesInt20Context window size (messages)
maxMessagesPerMinuteInt20Rate limit per agent
maxTokensPerDayInt100000Daily token budget per agent

Enums

info

All 27 tools are fully implemented and production-tested. The CUSTOM type is reserved for future company-specific tools.

Message Role Semantics

AgentMessage.role is one of USER, ASSISTANT, SYSTEM, TOOL. The interesting case is TOOL: tool results are persisted twice. Each tool execution writes a standalone TOOL row with the serialized output as its content, and the same output is embedded inside the parent ASSISTANT message's toolCalls[].output JSON column. The duplication is intentional — the TOOL rows exist for transcript/analytics exports that want a flat row-per-event view, while the embedded toolCalls[] is what the frontend actually renders.

Loader rule: any UI that maps conversationMessages(...) into a chat transcript must drop TOOL and SYSTEM rows at load time and render only USER + ASSISTANT. If TOOL rows leak through, every tool call will appear as a second "user" message with raw JSON content (because the frontend role switch is role === "assistant", so anything else falls through to the user visual). The assistant message already carries the complete toolCalls[] array with outputs, so dropping the TOOL rows loses nothing. See Loading Conversation History for the frontend implementation.

SYSTEM rows hold the rendered system prompt and are not meant for the UI either — drop them for the same reason.

History Reconstruction Invariants

get_conversation_history() in conversation_service.py rebuilds the Bedrock Converse messages array from the stored AgentMessage rows on every new turn. Bedrock has strict invariants and silent violations cause the next user turn to fail with ValidationException — the symptom users see is "after some turns the agent raises errors". The loader enforces:

  1. One user message per assistant toolUse turn. When an ASSISTANT row has multiple toolUse blocks (parallel tool calls), the loader scans ahead and batches every contiguous TOOL row into a single user message containing all matching toolResult blocks. Bedrock rejects split toolResults with Expected toolResult blocks at messages.X.content for the following Ids: …. The live continuation path (_build_tool_continuation_messages) batches correctly during streaming, so this only manifests on history replay.
  2. success → status propagation. Per-tool success/error from the assistant's toolCalls[] jsonb is propagated to toolResult.status ("success" or "error") on replay, mirroring the live path. Without this, replayed errors look like successes to the model and it hallucinates downstream.
  3. No empty text content blocks. Bedrock rejects [{"text": ""}] with The text field in the ContentBlock object … is blank. Empty TOOL content is replaced with (no output). Assistant rows with empty content and no toolCalls (zombie STREAMING rows from interrupted streams) are dropped entirely.
  4. No orphan toolUse blocks. If an assistant emitted toolUse but no matching TOOL rows landed (e.g., the server crashed between the assistant emit and the tool persist), the entire assistant row is dropped. Otherwise Bedrock rejects the next user message because its toolResult IDs don't match.
  5. Orphan TOOL rows are skipped. Legacy/corrupted TOOL rows with toolCallIds that don't match any preceding toolUse are skipped with a warning. Bedrock rejects toolResult blocks without a preceding toolUse.

The summarisation path (_summarize_context_if_needed) reads text from either content[0].text or content[0].toolResult.content[0].text so tool-result-only user messages (now common after the batching change) don't silently drop their content from the summary.

Stale STREAMING Janitor

ASSISTANT rows are created with status='STREAMING' and content='' before the LLM call begins, then transitioned to COMPLETED by update_content() once streaming finishes. If the stream is interrupted mid-flight (client disconnect, transient exception, OOM kill) the row can be stranded in STREAMING. On the next user turn the history loader would emit it as [{"text": ""}] and Bedrock would reject (see invariant 3 above).

Two layers prevent this:

  • try / except (GeneratorExit, asyncio.CancelledError) / except Exception / finally around the entire send_message_stream body. Strawberry/Starlette can raise either GeneratorExit (when aclose() is called on the generator) or asyncio.CancelledError (when the task is cancelled). Both are BaseException subclasses in Python 3.8+, so a bare except Exception does not catch them. Each branch updates the row to FAILED with a specific errorCode (CANCELLED, the exception text, or UNKNOWN for the finally fallback). Don't yield ChatError from inside the generator — @subscription_error_handler already converts the raised exception into a ChatError event, double-emission produces duplicate frames on the wire.
  • Startup janitor in app/main.py lifespan calls AgentMessageModel.cleanup_stale_streaming(max_age_seconds=300) after schema validation. It marks every STREAMING row older than 5 minutes as FAILED with errorCode='STREAM_TIMEOUT'. The WHERE clause hits the existing AgentMessage_status_idx and the operation is idempotent, so it is safe to run on a hot system. Wrapped in its own try/except so a janitor failure cannot block worker startup.

Message Processing Pipeline

When a user sends a message, the following pipeline executes:

Key Implementation Details

  • Message validation: Messages are capped at 10,000 characters (~2,500 tokens). Longer messages are rejected with a ValueError
  • RAG context injection: RAG results are injected into the user message as a [context]...[/context] block (not the system prompt), giving the LLM better signal about provenance
  • System prompt augmentation: When RAG returns results, the system prompt gets appended with: "When answering, use the [context] block provided in the user message. Cite the source document when relevant."
  • Multi-hop tool calls: The LLM can chain tool calls across up to 3 rounds with up to 10 tool calls per round. Each round tracks its own response text to avoid duplicating content in the conversation history
  • Context summarization: When conversation history exceeds maxContextMessages, older messages are summarized into bullet points using Nova Lite LLM. The summary is injected into the system prompt as [Previous conversation summary] and saved to the contextSummary field in the database
  • Non-blocking title generation: After the first user-assistant exchange (and every 5 messages), a concise title is generated asynchronously using Nova Lite LLM via asyncio.create_task(). The subscription closes immediately without waiting for the title. The frontend refetches the conversation list after a 3-second delay to pick up the new title
  • History reconstruction invariants: Parallel toolUse/toolResult batching, zombie-row drops, orphan handling, and per-tool error-status propagation are all enforced by format_history() — see History Reconstruction Invariants above for the full set

RAG Pipeline

Document Ingestion

The Document Worker (30-second polling cycle, 5 documents per batch) processes documents in parallel via asyncio.gather():

  1. Text Extraction -- Extracts raw text from uploaded documents using format-specific handlers (see supported file types below)
  2. Chunking -- Splits text using configurable strategies: recursive (default), token, or markdown. Uses LangChain text splitters when available, with custom fallback implementation
  3. Embedding -- Generates vector embeddings using Amazon Titan Embed v2 (1024 dimensions), batches of 10, with TTL-based caching (configurable via AGENT_EMBEDDING_CACHE_TTL, default 300s)
  4. Storage -- Persists chunks and embeddings to the AgentDocumentChunk table with pgvector

Each document has a configurable timeout (AGENT_DOCUMENT_TIMEOUT, default 600s). Timed-out documents are marked as FAILED automatically.

Document status progression: PENDINGPROCESSINGCHUNKINGEMBEDDINGCOMPLETED (or FAILED)

Supported file types:

FormatMIME TypeHandlerNotes
PDFapplication/pdfNative text extraction + OCR fallbackOCR uses AWS Bedrock for scanned PDFs
Word (.docx)application/vnd.openxmlformats-officedocument.wordprocessingml.documentpython-docx (paragraphs + tables)Recommended Word format
Word (.doc)application/mswordpython-docx attempt + raw text extractionLegacy format; best results with .docx
Excel (.xlsx)application/vnd.openxmlformats-officedocument.spreadsheetml.sheetopenpyxlAll sheets extracted
Excel (.xls)application/vnd.ms-excelopenpyxlLegacy Excel format
Plain texttext/plainDirect UTF-8 decodeIncludes .txt files
Markdowntext/markdownDirect UTF-8 decode.md files
CSVtext/csvDirect UTF-8 decodeComma-separated values
HTMLtext/htmlDirect UTF-8 decodeRaw HTML content
Imagesimage/*OCR via AWS BedrockJPEG, PNG, GIF, WebP, TIFF
tip

For best results with Word documents, use the modern .docx format. Legacy .doc files (pre-2007) undergo raw text extraction which may not preserve all formatting. If a .doc upload fails, save as .docx and re-upload.

Chunking strategies:

StrategySeparatorsBest For
recursive\n\n, \n, . , , , , ""General documents
tokenToken-based (4 chars/token estimate)Fixed token budgets
markdown## , ### , #### , ```, \n\n, \n, . Markdown documents

At query time, the system performs hybrid search combining two retrieval strategies:

  • Vector similarity (weight: 0.7) -- Cosine similarity between the query embedding and stored chunk embeddings via pgvector
  • Full-text search (weight: 0.3) -- PostgreSQL tsvector full-text search using the simple tokenizer (language-agnostic, not english) so non-English queries aren't penalized

When text_score > 0, the combined score is 0.7 * vector_score + 0.3 * text_score. Otherwise, falls back to pure vector score.

Diversity filter: After scoring, adjacent chunks from the same document (within 2 chunk indices) are deduplicated to avoid returning overlapping content.

Query embedding cache: A TTL-based LRU cache (100 entries, 300s TTL) avoids redundant embedding calls for repeated queries.

Tool System

Agents can be configured with tools that allow them to take actions during conversations. When the LLM determines a tool call is needed, it emits a structured tool call request via the Bedrock Converse API, the system executes it, and the result is fed back into the conversation.

Tool Definitions (Persistent)

Tool definitions are stored in the AgentToolDefinition database table with pre-computed pgvector embeddings for efficient tool selection. This replaces the previous hardcoded tool registry.

FieldTypeDescription
toolTypeAgentToolTypeEnum value matching the tool
toolCodeStringUnique identifier (e.g., rag_search)
nameStringDisplay name
descriptionTextNatural language description for LLM and UI
searchQueriesTextComma-separated search terms for embedding generation
categoryStringGrouping: data, recruitment, scheduling, ai_pipeline, insights
alwaysIncludeBooleanIf true, tool is always passed to LLM regardless of relevance
embeddingvector(1024)Pre-computed Titan Embed v2 embedding for Tool RAG
parametersSchemaJSONJSON Schema for tool parameters
isSystemBooleanSystem-provided (true) vs company-custom (false)
isActiveBooleanSoft-delete flag
versionIntBumped on update; triggers re-embedding
embeddingVersionIntTracks last embedded version (0 = never embedded)

On startup, background-tasks runs ensure_tool_embeddings() which embeds any tool definitions where embedding IS NULL or embeddingVersion < version.

Tool RAG (Semantic Tool Selection)

When an agent has 12+ tools enabled, the system uses Tool RAG to select the most relevant tools for each message instead of passing all tools to the LLM:

  1. Embed the user's message using Titan Embed v2
  2. Query AgentToolDefinition via pgvector cosine similarity (1 - (embedding <=> query))
  3. Filter by the agent's enabledTools list and minimum similarity score (0.25)
  4. Always include tools marked alwaysInclude (e.g., RAG_SEARCH)
  5. Return top-K relevant tools (default: 5) plus always-included tools

Below the threshold (fewer than 12 tools), all enabled tools are passed directly.

Tool Categories

CategoryToolsDescription
DataRAG_SEARCH, DATABASE_QUERYKnowledge base and database access
RecruitmentTALENT_SEARCH, JOB_SEARCH, CANDIDATE_SEARCH, COMPANY_INFO, PIPELINE_OVERVIEW, CREATE_TALENT, UPDATE_TALENT, UPDATE_CANDIDATE_STATUS, ADD_CANDIDATE_TO_JOB, ADD_TALENT_NOTERecruitment data and write actions
SchedulingINTERVIEW_SCHEDULE, MEETING_LISTCalendar and meeting data
AI PipelineCV_EXTRACTION, JOB_PARSING, RECOMMEND_TALENTS_FOR_JOB, RECOMMEND_JOBS_FOR_TALENT, CV_ANALYSIS, GENERATE_JOB_DESCRIPTION, COMPARE_CANDIDATESAI-powered document processing, matching, and analysis
CV OperationsCV_READ, CV_UPDATERead and modify structured CV data (available to both employers and job seekers)
InsightsHIRING_ANALYTICS, TALENT_POOL_INSIGHTS, GET_MATCH_SCORES, GET_TALENT_CV_DETAILS, SHORTLIST_CANDIDATES, GET_MEETING_INSIGHTAnalytics, reporting, and workflow automation

Implemented Tools (27 Total)

The tool system is divided into Read, Write, AI-Powered, and Analytics tools. All tools are company-scoped and include multi-tenant isolation.

Read Tools

These tools query data without modifying it.

ToolFunction NameDescriptionParametersReturns
RAG_SEARCHrag_searchSearch company knowledge base via hybrid vector + full-text searchquery (required), top_k (default 5), document_tags (array)Matching document chunks with content, filename, category, and similarity score
DATABASE_QUERYdatabase_queryExecute read-only SQL against whitelisted tablesquery (required, SELECT only), template_name, paramsQuery result rows (max 500), row count, truncation flag
TALENT_SEARCHsearch_talentsSearch talent pool by skills, experience, locationskills (array, max 20), experience_years, location, availability, limit (max 50)Talent profiles with name, title, location, skills, and CV info
JOB_SEARCHsearch_jobsSearch active job openings (excludes DRAFT/CLOSED)keywords, job_type (FREELANCE_GIG/DIRECT_HIRE/TRIAL_AND_HIRE), location, limit (max 50)Job listings with title, status, type, location, and skill requirements
COMPANY_INFOget_company_infoGet company details, team members, and statisticsinclude_members (default false), include_stats (default true)Company info, job/talent/candidate counts, team member list with roles
CANDIDATE_SEARCHsearch_candidatesFind candidates across pipelines with status filteringjob_id, status (NEW/IN_REVIEW/INTERVIEW/APPROVED/REJECTED/WITHDRAWN), keywords, limit (max 50)Candidates with name, status, job title, pipeline position, plus job-preference fields from CvJobPreferenceexpected_salary, salary_currency, salary_frequency, preferred_work_site, available_in, desired_job_titles (LEFT JOIN, null when the talent hasn't filled them in)
PIPELINE_OVERVIEWget_pipeline_overviewView candidate stage breakdown and funnel metricsjob_id (optional, omit for company-wide)Candidate counts per status, grouped by job
INTERVIEW_SCHEDULEget_interviewsList interviews with scheduling infostatus (PENDING/SCHEDULED/COMPLETED/CANCELLED/NO_SHOW), upcoming_only (boolean), limit (max 50)Interviews with date, participants, job context, and scores
MEETING_LISTget_meetingsView meetings with insight availabilitystatus (SCHEDULED/ACTIVE/COMPLETED/CANCELLED), upcoming_only (boolean), limit (max 50)Meetings with type, organizer, transcription and insight status
GET_TALENT_CV_DETAILSget_talent_cvGet full structured CV data for a talent — including CvJobPreference (expected salary, currency/frequency, preferred work site, availability, desired job titles). The tool description explicitly mentions salary so the LLM picks this tool for compensation queries.talent_id (required)Complete structured CV: profile (PII masked), work experience, education, skills, certifications, languages, projects, publications, awards, job preferences, and metadata. Every list item carries its row-level id (used by update_cv to target rows for update/remove).
CV_READread_cvRead full structured CV data by CV, user, or talent IDcv_id, user_id, or talent_id (at least one)Structured CV with profile, skills, experience, education, certifications, languages. Company-scoped for employer access, user-scoped for job seekers
GET_MATCH_SCORESget_match_scoresRetrieve pre-computed match scores from the background matching enginetalent_id, job_id, min_score (0-100, default 0), limit (max 50)Talent-job scores with breakdown (skills, experience, salary, location), calculated timestamp
GET_MEETING_INSIGHTget_meeting_insightRetrieve AI-generated meeting/interview insightsmeeting_id, interview_id, candidate_id (provide at least one)Skills assessment, communication analysis, key topics summary, red flags, full report

Write Tools

These tools modify data in the database. All write operations verify company ownership and create audit trails. Detail-page write tools support entity fallback — on the relevant detail page, the entity UUID can be omitted and the handler uses self.context.entity.entity_id automatically. See Entity Fallback Resolution.

ToolFunction NameDescriptionParametersReturns
CREATE_TALENTcreate_talentCreate new talent in the company's talent poolname (required), email (required, regex validated), phone, location, title, linkedin_url, skills (array), biographyCreated talent ID, name confirmation. Creates User + TalentPool link + Talent (MANUAL_ENTRY source)
UPDATE_TALENTupdate_talentUpdate an existing talent's profile informationtalent_id (optional on talent_detail), first_name, last_name, title, location, phone, linkedin_url, biographyUpdated talent ID, list of changed fields
UPDATE_CANDIDATE_STATUSupdate_candidate_statusMove a candidate through the recruitment pipelinecandidate_id (optional on candidate_detail), status (required: NEW/IN_REVIEW/INTERVIEW/APPROVED/REJECTED/WITHDRAWN), reason (for REJECTED/WITHDRAWN)Old status, new status, candidate name, job title
EDIT_CANDIDATEedit_candidateEdit non-status candidate fields (motivation, report, expected salary). Status changes go through update_candidate_status.candidate_id (optional on candidate_detail), motivation, report, withdrawal_reason, expected_salary, salary_frequency (HOURLY/DAILY/WEEKLY/MONTHLY/YEARLY), orderUpdated candidate ID, changed fields
ADD_CANDIDATE_TO_JOBadd_candidate_to_jobAdd an existing talent from the pool to a specific job as a candidatetalent_id (optional on talent_detail), job_id (optional on job_detail), motivation (notes)Created candidate ID, name, job title, initial status (NEW). Prevents duplicate candidacy
ADD_TALENT_NOTEadd_talent_noteAdd a note to a talent's profile (job-independent observations)talent_id (optional on talent_detail), content (required, markdown supported), title (optional)Created note ID, talent name. Author is the current user
CONTACT_TALENTcontact_talentOpen (or reuse) a DIRECT Conversation with the talent's user and post a Message. Creates ConversationParticipant rows and updates the denormalized lastMessageAt/lastMessageText/messageCount fields.talent_id (optional on talent_detail), message (required)Conversation ID, message ID, recipient name
EDIT_JOBedit_jobUpdate fields on an existing Job posting via a strict allowlistjob_id (optional on job_detail), title, description, location, salary, currency, job_type (enum), work_site (REMOTE/ONSITE/HYBRID), status (enum), priority, published_onlineUpdated job ID, changed fields
DUPLICATE_JOBduplicate_jobClone a Job as a new DRAFT with " (Copy)" appended to the title. The new job has zero candidates.job_id (optional on job_detail)New job ID, source job ID
CV_UPDATEupdate_cvAdditive updates to a CV via direct SQL writes — never deletes existing rows unless explicitly listed in remove[]. Each list section takes an envelope { add: [...], update: [{id, ...}], remove: [<id>, ...] }; the 1:1 sections (profile, job_preference) take a flat object that is upserted.cv_id (optional when the caller owns the active CV), updates (object with optional keys: profile, job_preference, skills, work_experiences, educations, certifications, languages — singular aliases work_experience / education / certification / language / jobPreference / preferences are also accepted)Updated section list, section_summary ({added, updated, removed} per section), ignored_keys, CV ID. Writes inside a single transaction across CvProfile, CvJobPreference, CVSkill, CvWorkExperience, CvEducation, CvCertification, CvLanguage.
info
update_cv — additive envelope (SUP-00153, 2026-04-24)

The previous implementation replaced every list section wholesale (DELETE FROM "X" WHERE "cvId" = $1 followed by inserts of whatever the LLM sent back). When the model regenerated a section but omitted any pre-existing row — a common LLM behaviour — that row was silently lost. The handler now consumes an explicit envelope per list section:

{
"updates": {
"profile": { "firstName": "...", "bio": "..." },
"job_preference": {
"expectedSalary": 80000, "salaryCurrency": "EUR", "salaryFrequency": "MONTHLY",
"preferredJobTypes": ["FULL_TIME"], "preferredWorkSite": "REMOTE",
"availableIn": "IMMEDIATELY", "desiredJobTitles": ["Senior Backend Engineer"]
},
"skills": { "add": [{"name":"Python"}], "update": [{"id":"...", "proficiency":"EXPERT"}], "remove": ["..."] },
"work_experiences": { "add": [...], "update": [{"id":"...", ...}], "remove": ["..."] },
"educations": { "add": [...], "update": [{"id":"...", ...}], "remove": ["..."] },
"certifications": { "add": [...], "update": [{"id":"...", ...}], "remove": ["..."] },
"languages": { "add": [...], "update": [{"id":"...", ...}], "remove": ["..."] }
}
}
  • add[] only inserts; existing rows are never touched (CVSkill adds use ON CONFLICT (cvId, skillId) DO UPDATE so re-adding a skill is idempotent and can also bump proficiency).
  • update[] requires the row's id (call read_cv first if the LLM doesn't have it). Updates are scoped by WHERE id = $row_id AND "cvId" = $cv_id so a hallucinated id can't write across CVs.
  • remove[] is a list of row ids, also scoped by cvId.
  • job_preference is a new section that closes the previous CvJobPreference write gap (expected salary, currency/frequency, preferred work site, availability, desired job titles).
  • Backwards compatibility shim: if a list section arrives as a bare list (legacy shape — old prompt cached in an in-flight conversation) the handler treats it as {"add": list} and logs a warning. To be removed after one release.
info
update_cv — surfacing LLM key drift (SUP-00130, 2026-04-21)

The handler normalises common LLM mistakes (work_experiencework_experiences, educationeducations, certificationcertifications, languagelanguages) and echoes an ignored_keys array on every result — both successes and failures — so the caller can see exactly which sections the agent tried to update but the handler couldn't interpret. When every supplied key is unrecognised, the tool returns a structured error with the supported_sections list instead of the former silent success=False / "No valid sections".

Self Tools

Broad user context tools that operate on the caller. These need no entity UUID — the handler reads everything from self.context.user, which is populated by context_builder.build_agent_context at the top of every turn.

ToolFunction NameDescriptionParametersReturns
GET_MEget_meReturn the caller's full user profileuser_id, name, email, view_mode, company_id, company_name, role_in_company, plan, billing_mode, subscription_status, credit_balance, active_cv_id, is_super_admin
GET_MY_STATSget_my_statsSummary counters scoped to the caller. Employers: jobs, talents, candidates, upcoming interviews. Job seekers: applications and upcoming interviews.scope (company/self) + counter fields
GET_MY_SUBSCRIPTIONget_my_subscriptionPlan, billing mode, status, price, credit balance, trial/renewal dates. Resolves via Company.billingOwnerId for employers or Subscription.userId for job seekers.plan, billing_mode, subscription_status, credits_per_month, price, currency, interval, trial_end, end_date, credit_balance
GET_MY_RECENT_ACTIVITYget_my_recent_activityRecent notifications and credit transactions for the callerlimit (default 10, max 50)notifications[], credit_transactions[]
UPDATE_MY_PROFILEupdate_my_profileUpdate the caller's own User row via a strict allowlist. Email is validated against a regex.first_name, last_name, email, phone, title, location, biography, linkedin_url, portfolio_url (all optional, at least one required)updated_fields list

AI-Powered Tools

These tools invoke LLM inference or the matching engine. They use Haiku 4.5 for fast responses and structured JSON output.

ToolFunction NameDescriptionParametersReturns
CV_EXTRACTIONextract_cvExtract structured data from CV documents via full OCR pipeline. The parsed CV is saved as a draft (via save_intake_draft()) — it lands in the Draft CV Inbox for review, not directly in the pool as a talentfile_url (required, S3 path or URL)Parsed CV with structured sections. Calls CVExtractionService with 120s timeout
JOB_PARSINGparse_jobParse job descriptions into structured requirementstext or file_url (one required)Structured job data: title, skills, requirements, qualifications. Calls JobParsingService with 90s timeout
RECOMMEND_TALENTS_FOR_JOBrecommend_talents_for_jobFind and rank best-matching talents from the talent pool for a jobjob_id (required), min_score (0-100, default 40), limit (max 20)Ranked talent list with match scores and breakdown (skills, experience, salary, location) from UserJobMatchScore
RECOMMEND_JOBS_FOR_TALENTrecommend_jobs_for_talentFind and rank best-matching active jobs for a specific talenttalent_id (required), min_score (0-100, default 40), limit (max 20)Ranked job list with match scores and breakdown. Excludes DRAFT/CLOSED jobs
CV_ANALYSISanalyze_cvDeep AI analysis of a talent's CV: career trajectory, strengths, concerns, and skill gapstalent_id (reads active CV), cv_id (alternative direct CV reference), job_id (optional, for gap analysis)JSON analysis: career_trajectory, seniority_level, key_strengths (3-5), potential_concerns, skill_gaps (if job context), recruiter_recommendations, overall_assessment
GENERATE_JOB_DESCRIPTIONgenerate_job_descriptionAI-generate a professional job description from parameterstitle (required), skills (array, max 15), seniority_level (Entry/Junior/Mid/Senior/Lead/Principal), job_type, work_site (REMOTE/HYBRID/ONSITE), location, additional_requirementsJSON: title, summary, responsibilities (5-8), requirements (5-8), nice_to_have (3-5), benefits (4-6). Includes company context from DB
COMPARE_CANDIDATEScompare_candidatesSide-by-side AI comparison of 2-5 candidates for a jobcandidate_ids (required, array of 2-5 UUIDs), job_id (required)Candidate profiles with match scores, plus AI analysis: comparison_summary, recommendation, key_differences (3-5)

Analytics Tools

These tools provide aggregated insights and support workflow automation.

ToolFunction NameDescriptionParametersReturns
HIRING_ANALYTICSget_hiring_analyticsHiring funnel analytics: conversion rates, time-to-hire, velocityjob_id (optional, omit for company-wide), days_back (7-365, default 90)total_candidates, candidates_by_status, conversion_rates_pct (new→review, review→interview, interview→approved), hiring_velocity_per_month, candidates_per_job_avg, active_jobs_with_candidates
TALENT_POOL_INSIGHTSget_talent_pool_insightsTalent pool health: skill distribution, source breakdown, growth trendstalent_pool_id (optional, omit for all pools)total_talents, by_source (e.g. MANUAL_ENTRY, INVITED, SHARED, CV_UPLOAD, plus intake sources BULK_IMPORT / AI_AGENT_UPLOAD / AI_EMAIL_UPLOAD for promoted drafts), top_skills (top 20), with_cv/without_cv counts, growth (30/60/90 day new talent counts)
SHORTLIST_CANDIDATESshortlist_candidatesBulk advance candidates to IN_REVIEW statusjob_id (required), min_score (auto-shortlist above threshold), candidate_ids (explicit selection), max_candidates (1-25, default 10)Number shortlisted, candidate names. Only advances candidates currently in NEW status

Tool Management API

Tool definitions can be managed via GraphQL:

# Query available tools for a company
query ToolDefinitions($companyId: String!) {
toolDefinitions(companyId: $companyId) {
success
tools { id toolType toolCode name description category alwaysInclude isSystem isActive }
}
}

# Update a tool definition (bumps version, triggers re-embedding)
mutation UpdateToolDefinition($input: UpdateToolDefinitionInput!) {
updateToolDefinition(input: $input) { id name description isActive }
}

# Toggle a tool active/inactive
mutation ToggleToolDefinition($toolId: String!, $isActive: Boolean!) {
toggleToolDefinition(toolId: $toolId, isActive: $isActive) { success message }
}

Tool Resilience

All tool handlers are wrapped with retry and circuit breaker protection:

  • Retry with backoff: Up to 5 retries with exponential backoff (1s to 15s) for transient errors (DB connection drops, timeouts, network errors)
  • Circuit breaker: Module-level per-tool-type circuit breakers (shared across all conversations). After 8 consecutive failures, the circuit opens and returns fast errors for 30 seconds before testing recovery. Configurable: failure_threshold=8, reset_timeout=30s, call_timeout=90s
  • Transient exceptions: asyncpg.PostgresConnectionError, asyncpg.InterfaceError, asyncio.TimeoutError, ConnectionError, OSError

Non-transient errors (validation failures, permission denied) are returned immediately without retry.

Tool Security Hardening

All tools include security measures to prevent abuse and ensure data integrity:

ProtectionScopeDetails
Limit bounds checkingAll search toolsmax(1, min(limit, 50)) — prevents negative or extreme LIMIT values
Recommendation limitsRECOMMEND_TALENTS_FOR_JOB, RECOMMEND_JOBS_FOR_TALENTMax 20 results per request
Shortlist capSHORTLIST_CANDIDATESMax 25 candidates per bulk operation
Candidate comparison capCOMPARE_CANDIDATESExactly 2-5 candidates required
Skills array capTALENT_SEARCH, GENERATE_JOB_DESCRIPTIONMax 20 and 15 skills respectively
Score boundsAll tools with min_scoreClamped to 0-100 range
Timeout protectionCV_EXTRACTION, JOB_PARSINGasyncio.wait_for() with 120s and 90s timeouts respectively
SQL safetyDATABASE_QUERYSELECT-only, CTE blocking, subquery blocking, error messages sanitized
Row capDATABASE_QUERYMax 500 rows returned regardless of LIMIT
Output truncationAll toolsTool output truncated to 50KB before database save (json.dumps(default=str))
Email validationCREATE_TALENTRegex check on email format before user creation
Duplicate checkADD_CANDIDATE_TO_JOBPrevents duplicate candidacy for the same user+job pair
Status validationUPDATE_CANDIDATE_STATUSOnly allows valid CandidateStatus enum values
PII maskingGET_TALENT_CV_DETAILS, DATABASE_QUERYPII fields (email, phone, DOB, address) masked as ***MASKED***
Ownership verificationAll write toolsVerifies talent/candidate/job belongs to the agent's company before modification
CV access helperCV_READ, CV_UPDATE_assert_cv_access(cv_id) is the single source of truth for CV authorization. Company agents must own the underlying talent-pool or be in a UserCompanyRole with the CV owner; jobseeker agents must be the CV's owning user; missing caller identity is always denied. Both read and update paths go through this helper so they can't drift apart.
Audit trailAll write toolsuser_id recorded as author for notes, the updatedAt timestamp is set
Parameterized queriesAll toolsNo string interpolation in SQL — all values passed as parameters
Company scopingAll toolsEvery query filtered by company_id for multi-tenant isolation

Tool Name→Type Mapping

The AgentToolService.get_name_to_type_mapping() method provides a single source of truth for mapping tool names to their types. This mapping is used by conversation_service.py when parsing streaming events, eliminating the need for a hardcoded duplicate dictionary.

Database Access Configuration

The DATABASE_QUERY tool requires an AgentDatabaseConfig record. Queries are validated by _validate_sql_safety():

  1. Comment stripping -- Block comments (/* ... */) and line comments (-- ...) are removed before validation
  2. Semicolon blocking -- Multiple statements rejected to prevent injection chaining
  3. SELECT only -- Query must start with SELECT after comment stripping
  4. CTE blocking -- Common Table Expressions (WITH ... AS (...)) are rejected to prevent restriction bypass
  5. Subquery blocking -- Nested (SELECT ...) expressions are rejected to prevent exfiltration
  6. Forbidden keywords -- Word-boundary regex matching for: DROP, DELETE, UPDATE, INSERT, ALTER, TRUNCATE, EXEC, EXECUTE, CREATE, GRANT, REVOKE, UNION, INTO OUTFILE, INTO DUMPFILE, COPY, SET, LOAD
  7. Table restrictions -- Denied tables checked first (blocklist priority), then allowed tables whitelist
  8. Row limits -- Auto-appends LIMIT if not present
  9. PII masking -- Fields containing email, phone, ssn, address, date_of_birth masked as ***MASKED***
  10. Salary masking -- Fields containing salary, compensation, pay, wage, income masked as ***CONFIDENTIAL***
Config FieldTypeDefaultDescription
accessLevelDatabaseAccessLevelREAD_ONLYNONE, READ_ONLY, READ_WRITE
allowedTablesString[][]Whitelist of table names
deniedTablesString[][]Blacklist of table names (takes priority)
maxRowsPerQueryInt100Max rows returned per query
queryTimeoutMsInt5000Query timeout in milliseconds
allowJoinsBooleantrueAllow JOIN operations
allowAggregationsBooleantrueAllow GROUP BY / aggregations
allowSubqueriesBooleanfalseAllow subqueries
tablePermissionsJSON{}Per-table access rules: { "tableName": { "access": "read", "columns": ["col1"] } }
columnRestrictionsJSON{}Column-level rules: { "table.column": "mask|hide|allow" }
maskPIIBooleantrueMask personally identifiable information
hideSalaryDataBooleantrueHide salary fields
hidePersonalNotesBooleantrueHide personal notes
queryTemplatesJSON[]Pre-approved queries: [{ "name": "get_candidates", "query": "SELECT...", "params": ["jobId"] }]
logAllQueriesBooleantrueLog all queries for audit

Each tool execution is logged in AgentToolExecution with input parameters, output, duration, SQL query details, RAG search metadata, and success/failure status.

Tool Auto-Seeding

Tool definitions are automatically synchronized from code to the AgentToolDefinition database table on every application startup. This ensures that newly added tools are immediately available without manual database operations.

How it works:

  1. AgentToolService.seed_system_tools() runs first. It creates a temporary instance, calls _register_fallback_tools() to get the 27 hardcoded tool definitions, then upserts each one into the AgentToolDefinition table by toolType. If a tool already exists in DB, only fields that actually changed are updated (preserving existing embeddings). Stale system tools (tools removed from code) are cleaned up automatically.

  2. ensure_tool_embeddings() runs second. It queries for any tool definitions where embedding IS NULL or embeddingVersion &lt; version. For each, it generates an embedding from the concatenation of name + description + searchQueries using Amazon Titan Embed v2, stores the 1024-dimension vector, and updates embeddingVersion to match version.

Loading priority: When an agent processes a message, _ensure_tools_loaded() attempts to load tool definitions from the database first (respecting any customizations made via the GraphQL API). The hardcoded fallback definitions are only used if the database load fails, ensuring new tools work immediately even before the auto-seeder runs.

Category mapping during seeding:

CategoryTool Types
dataRAG_SEARCH, DATABASE_QUERY
recruitmentTALENT_SEARCH, JOB_SEARCH, CANDIDATE_SEARCH, COMPANY_INFO, PIPELINE_OVERVIEW, CREATE_TALENT, UPDATE_TALENT, UPDATE_CANDIDATE_STATUS, ADD_CANDIDATE_TO_JOB, ADD_TALENT_NOTE
ai_pipelineRECOMMEND_TALENTS_FOR_JOB, RECOMMEND_JOBS_FOR_TALENT, CV_ANALYSIS, GENERATE_JOB_DESCRIPTION, CV_EXTRACTION, JOB_PARSING, COMPARE_CANDIDATES
cv_operationsCV_READ, CV_UPDATE
insightsHIRING_ANALYTICS, TALENT_POOL_INSIGHTS, GET_MATCH_SCORES, GET_TALENT_CV_DETAILS, SHORTLIST_CANDIDATES, GET_MEETING_INSIGHT
schedulingINTERVIEW_SCHEDULE, MEETING_LIST

Streaming Protocol

The agentChat GraphQL subscription emits a sequence of typed events as processing progresses:

info

Title generation is non-blocking. The subscription closes at ChatComplete without waiting for the title. The frontend refetches conversations after a 3-second delay to pick up the new title from the database.

Event Types

EventFieldsDescription
ChatProcessingid, status, messageRequest acknowledged, processing started
ChatContextLoadedid, contextChunks, contextTokensRAG context retrieved (only if RAG enabled and results found)
ChatChunkid, delta, chunkIndexStreaming text delta of the final response
ChatReasoningChunkid, delta, chunkIndexStreaming extended-thinking delta from a reasoning-capable model. See Reasoning Streaming below.
ChatToolCallid, toolName, toolType, inputDataAgent invoked a tool (may occur multiple times across multi-hop rounds)
ChatToolResultid, toolCallId, output, success, executionTimeMsTool execution result
ChatCompleteid, messageId, content, inputTokens, outputTokens, totalTimeMsFull response with final content and token metrics
ChatErrorid, code, message, recoveryHintError occurred during processing

Reasoning Streaming

When the agent's llmModel supports extended thinking (Claude Sonnet 4.x, Opus 4.x, Claude 3.7 Sonnet — see AI Models), the service enables Bedrock's additionalModelRequestFields.thinking and the Converse Stream returns a separate reasoningContent block alongside the usual text block. The agent service routes those deltas to a dedicated ChatReasoningChunk event so the frontend can render thinking in a collapsible "Show reasoning" surface — separate from the final answer chunks.

Frontend invariants:

  • ChatChunk.delta is appended to msg.content (the canonical assistant message content shown in conversation history previews).
  • ChatReasoningChunk.delta is never appended to msg.content. It accumulates into a { type: "reasoning", text } segment on msg.contentSegments. This way conversation list previews show the answer, not the chain of thought.
  • The frontend coalesces consecutive reasoning chunks into one segment so the UI shows a single collapsible block per thinking pass rather than a fragmented stream.
  • The subscription must select the ChatReasoningChunk inline fragment explicitly (... on ChatReasoningChunk { id delta chunkIndex }). Without it, graphql-ws still routes the events but every field arrives as undefined and the UI ends up with empty/NaN-looking blocks.

Tool-handler reasoning pipe

A few tool handlers (_handle_cv_analysis, _handle_generate_job_description, _handle_compare_candidates) ALSO call Bedrock with extended thinking enabled — to improve the structured-output quality of their assessments. Their reasoning would normally be invisible (the tool returns only its final structured result), but the agent loop forwards those deltas to the same ChatReasoningChunk event stream so the UI shows two waves of "Thinking…" per turn (the agent reasoning about which tool to call, then the tool reasoning while it executes).

Implementation: a per-task ContextVar pipe (app/services/agent/reasoning_pipe.py::REASONING_PIPE) holds an asyncio.Queue[str]. The tool helper _stream_thinking_converse in tool_service.py runs client.converse_stream() inside run_in_executor and pushes each reasoningContent.text delta back to the asyncio thread via loop.call_soon_threadsafe(pipe.put_nowait, …). send_message_stream runs _execute_tool_calls as a background task and concurrently drains the queue with asyncio.wait(FIRST_COMPLETED), yielding each delta as a ReasoningChunk.

Two non-obvious correctness rules apply:

  • Cross-thread: ContextVars do NOT propagate across run_in_executor thread boundaries, and asyncio.Queue.put_nowait() is not thread-safe. The handler must read (loop, pipe) in the asyncio thread and pass them as plain arguments to the executor body.
  • Async-generator context: use REASONING_PIPE.set(pipe) + set(previous) to save/restore — never .reset(token). Strawberry can resume the agent generator in a different Context across yield boundaries, which makes reset(token) raise "Token was created in a different Context".

Attachments

Users can attach files to messages via the attachments field on the agentChat subscription input. The agent service turns each attachment into a Bedrock Converse multimodal content block (image or document) so a reasoning-capable Claude model can read the file directly without calling a tool.

Supported MIME types and limits live in app/services/agent/attachment_processor.py:

CategoryMIME typesPer-file capNotes
Imageimage/jpeg, image/png, image/gif, image/webp5 MBBedrock image cap
Documentapplication/pdf, application/msword (.doc), …wordprocessingml.document (.docx), application/vnd.ms-excel (.xls), …spreadsheetml.sheet (.xlsx), text/plain, text/markdown, text/html, text/csv4.5 MBBedrock document cap (tighter than images)

Total per message: 20 MB across at most 5 attachments. Frontend pre-checks the same limits — see aiqlick-frontend/lib/config/attachmentLimits.ts.

Document name dedupe (Bedrock invariant)

Bedrock rejects requests where any two document.name fields collide with Messages can't contain duplicate document names. This trips up the obvious case (user attaches the same filename in two consecutive turns — both turns get rebuilt into the request on the second send) AND a less obvious case (frontend uploads two files at once with the same name). The processor enforces these rules:

  1. _safe_doc_name(filename, used) strips the extension, replaces every char outside [A-Za-z0-9 \-()\[\]] with a space, collapses runs of whitespace, truncates to 180 chars, and appends -2/-3/… on collision. Mutates used to record the chosen name.
  2. The used set is built once at the top of send_message_stream and threaded through both get_conversation_history (for prior turns' attachments) AND process_attachments (for the new turn's uploads), so dedupe is per-Bedrock-request, not per-call.

Bedrock's actual allowed character class is alphanumeric + whitespace + hyphens + parentheses + square brackets, with no consecutive whitespaces. Underscores, periods, slashes, and non-ASCII chars are NOT allowed in the name field — that's why Senior_Software_Developer_CV.pdf and top 20 i.t companies.pdf both used to fail until we replaced the disallowed chars with spaces.

Failure surface — fail loud, not silent

When a new-turn attachment fails to download from S3 or its actual bytes exceed the size cap, process_attachments raises AttachmentProcessingError(filename, reason). send_message_stream catches this BEFORE creating the user message + assistant placeholder rows (so neither becomes a zombie), yields a service-layer AttachmentFailed(filename, reason) event, and returns. The subscription handler maps AttachmentFailedChatError(code="ATTACHMENT_FAILED", recoveryHint="Re-upload the file and try again. If the problem persists, the file format may not be supported.").

The frontend (ChatPanel.tsx, ChatModal.tsx) restores pendingAttachments from a snapshot when ATTACHMENT_FAILED is observed so the user can retry without re-selecting the files. See Frontend AI Agent for the full UX.

For the history-replay path (rebuild_attachment_content_blocks), failure mode is different — an old file that was deleted from S3 should not break ongoing conversations, so the replay path falls back to a [Previously attached: …] text placeholder rather than raising.

URL-decode S3 key — backward compat

An older version of the aiqlick-frontend/app/api/upload/route.ts route stored urlObj.pathname directly as the S3 key, which is URL-percent-encoded (spaces → %20, parens → %28/%29, non-ASCII → %CX%XX). The actual S3 object lives at the literal key, so a backend GetObject with the encoded form returned 404 and the attachment was silently dropped. Two fixes shipped:

  1. The upload route now decodeURIComponents the path before persisting (commit fcbf020b in aiqlick-frontend).
  2. _download_file retries with unquote(key) on initial failure as a belt-and-braces fallback for any historical rows still in flight.

A one-shot Python migration (urllib.parse.unquote per row) backfilled the 5 historical AgentMessageAttachment.key rows in dev RDS that were stored encoded.

Error Codes

CodeDescriptionRecovery
CONVERSATION_NOT_FOUNDInvalid conversation IDCreate a new conversation
CONVERSATION_INACTIVEConversation archived or deletedStart a new conversation
UNAUTHORIZEDNo access to this conversationCheck JWT and company scope
RATE_LIMITEDToo many messages per minuteWait and retry
TOKEN_LIMIT_EXCEEDEDDaily token budget exhaustedWait for reset or increase limit
CHAT_ERRORLLM processing errorRetry the message
TOOL_ERRORTool execution failedCheck tool configuration
INSUFFICIENT_CREDITSCredit balance too lowPurchase more credits
SUBSCRIPTION_INACTIVESubscription not activeActivate subscription
PLAN_LIMIT_EXCEEDEDPlan limit reachedUpgrade plan
ATTACHMENT_FAILEDA user-provided attachment couldn't be downloaded from S3 or its actual bytes exceeded the size limit. Surfaced BEFORE the LLM call so the model never sees an incomplete request.Re-upload the file and try again. The frontend restores pendingAttachments from a snapshot so the user keeps their selections.

Billing Integration

The agent chat is fully integrated with the billing system:

  1. Pre-check (BillingGate.pre_check): Before LLM call, validates subscription status (ACTIVE/TRIALING), plan limits, and credit balance
  2. Consumption (BillingGate.check_and_consume): After response completes, calculates cost from token usage and deducts from credit balance
  3. Failure logging (BillingGate.log_failure): On errors, logs the failure for audit trail
  4. Enterprise bypass: POSTPAID plans skip credit checks; usage is logged for monthly invoicing

Billing errors emit specific ChatError codes (INSUFFICIENT_CREDITS, SUBSCRIPTION_INACTIVE, PLAN_LIMIT_EXCEEDED) that the frontend handles via BillingErrorToastHandler.

Task Configurations

Agents support task-specific configurations via AgentTaskConfig. A single agent can be optimized for different workflows by linking conversations to task codes.

FieldDescription
taskCodeUnique identifier per agent (e.g., recruitment_chat, talent_search)
systemPromptOverride agent's default system prompt
temperature / maxTokensOverride LLM parameters
ragEnabled / ragTopK / ragMinScoreOverride RAG settings
ragDocumentTagsFilter RAG search to specific document tags
enabledToolsOverride enabled tools for this task
fewShotExamplesJSON few-shot examples for better performance
outputFormat / outputSchemaOutput formatting constraints
maxResponseLengthLimit response length
requiredFields / forbiddenTopicsContent guardrails

Workflow Service (LangGraph)

The workflow service provides graph-based agent orchestration for complex multi-step tasks using LangGraph (optional dependency):

  • LangGraph mode: Uses StateGraph with conditional edges for analyze → tool execution → synthesize flow
  • Fallback mode: Simple sequential execution when LangGraph is not installed
  • Max iterations: Configurable limit (default: 10) to prevent infinite loops

Voice Integration

The agent system supports voice conversations via Amazon Nova Sonic 2.0:

ParameterValue
Endpointwss://{host}/voice/ws/{conversation_id}
Audio FormatBinary PCM, 16kHz sample rate, 16-bit depth, mono channel
Audio BufferingMIN=16000 bytes (~0.5s), PROCESS=32000 (~1s), MAX=960000 (~30s)
Control MessagesJSON (session config, turn signals, interrupts)
Modelamazon.nova-2-sonic-v1:0
Languages10 supported (en-US/GB/IN/AU, es-ES, de-DE, fr-FR, it-IT, pt-BR, hi-IN)
Voicestiffany (female), matthew (male) -- both polyglot
Session Timeout600 seconds (10 minutes)
warning

Swedish (sv-SE) is NOT supported by Nova Sonic despite being the platform's primary market.

The frontend connects via the NestJS voice WS proxy at ${NEXT_PUBLIC_API_URL}/voice/ws/<conversationId>. The gateway terminates the user JWT, mints an internal-service JWT, and opens an upstream WS to background-tasks. Audio is streamed bidirectionally -- the user's microphone input is sent as binary frames, and the agent's spoken response is received as binary PCM for playback.

Frontend Integration

The AI agent system runs on the background-tasks service, not the main backend. The frontend uses dedicated environment variables to connect:

VariablePurpose
NEXT_PUBLIC_API_URLBackend HTTP endpoint (also AI gateway HTTP via queryAi/mutateAi and voice WS path)
NEXT_PUBLIC_GRAPHQL_WS_URLBackend WS endpoint (also agentChat and other AI subscriptions via subscribeAi)

The NEXT_PUBLIC_AI_* env vars were removed in the Phase 3.7 cutover — the frontend now talks only to the backend, which proxies AI traffic to background-tasks.

This separation allows the AI service to scale independently and prevents heavy LLM workloads from affecting the core API.

Activity Tracking

All agent operations are logged to AgentActivity with 27 activity types:

CategoryActivity Types
Agent lifecycleAGENT_CREATED, AGENT_UPDATED, AGENT_ENABLED, AGENT_DISABLED
ConfigurationTASK_CONFIG_CREATED, TASK_CONFIG_UPDATED, DATABASE_CONFIG_UPDATED
ConversationsCONVERSATION_STARTED, CONVERSATION_RESUMED, CONVERSATION_ARCHIVED
MessagesMESSAGE_SENT, MESSAGE_RECEIVED
ToolsTOOL_EXECUTED, TOOL_FAILED
DocumentsDOCUMENT_UPLOADED, DOCUMENT_PROCESSED, DOCUMENT_DELETED, DOCUMENT_RAG_ENABLED, DOCUMENT_RAG_DISABLED
SearchRAG_SEARCH_PERFORMED, DATABASE_QUERY_EXECUTED
SystemCONTEXT_SUMMARIZED, STREAMING_STARTED, STREAMING_COMPLETED, ERROR_OCCURRED, FEEDBACK_RECEIVED

Additional GraphQL Operations

Beyond the core CRUD and agentChat subscription, the following queries and mutations are available:

Clone Agent

mutation CloneAgent($agentId: String!, $newName: String!) {
cloneAgent(agentId: $agentId, newName: $newName) {
success
message
agent { id name type status }
}
}

Copies an agent's full configuration (LLM settings, system prompt, RAG config, enabled tools, rate limits) into a new agent. Usage statistics and conversations are not copied. Requires access to the source agent's company.

Export Conversation

query ExportConversation($conversationId: String!) {
exportConversation(conversationId: $conversationId) {
conversationId
title
agentName
messages {
role
content
timestamp
toolCalls
}
totalInputTokens
totalOutputTokens
createdAt
exportedAt
}
}

Returns all messages in a conversation as structured data. Messages are ordered chronologically (ascending). Tool calls are included as JSON when present.

Agent Analytics

query AgentAnalytics($agentId: String!, $days: Int = 30) {
agentAnalytics(agentId: $agentId, days: $days) {
agentId
agentName
totalConversations
totalMessages
totalTokensUsed
toolUsage {
toolType
executionCount
avgExecutionTimeMs
successRate
}
dailyUsage {
date
messageCount
tokenCount
}
}
}

Aggregates usage statistics for an agent over the specified period. Tool usage stats are sourced from AgentToolExecution and daily usage from AgentTokenUsage.

Environment Variables

VariableDefaultDescription
AGENT_DEFAULT_LLM_MODELeu.anthropic.claude-haiku-4-5-20251001-v1:0Default LLM model for agents (upgraded from Nova Lite for better quality)
AGENT_LLM_REGIONeu-north-1AWS region for agent LLM calls
AGENT_EMBEDDING_MODELamazon.titan-embed-text-v2:0Embedding model for RAG
AGENT_DOCUMENT_WORKER_ENABLEDtrueEnable document processing worker
AGENT_DOCUMENT_POLL_INTERVAL30Document processing poll interval (seconds)
AGENT_DOCUMENT_BATCH_SIZE5Documents to process per batch
AGENT_DOCUMENT_MAX_RETRIES3Max retries for failed documents
AGENT_DOCUMENT_TIMEOUT600Per-document processing timeout (seconds)
AGENT_EMBEDDING_CACHE_TTL300Embedding query cache TTL (seconds)