Skip to content

Agent & Retrieval System

The agentic RAG pipeline is the core of CampusCore's chat experience. A ReAct-style reasoning agent decides what tools to call, observes results, and generates a grounded response. This document covers the agent architecture, retrieval tools, and attachment handling.

Architecture Overview

User message
    │
    ▼
ChatService.generate_response()
    │
    ├── 1. Get/create conversation
    ├── 2. Link file attachments
    ├── 3. Save user message
    ├── 4. Build system prompt (AppConfig + guardrails)
    └── 5. agent.run()
            │
            ├── Build attachment context (files + folders → system prompt)
            ├── Wire tools with user/conversation context
            └── ReAct loop:
                 ├── LLM call → tool calls or text response
                 ├── Execute tools → observe results
                 ├── Token management (prevent context overflow)
                 └── Repeat until done or max iterations

Key Files

File Role
services/chat/chat_service.py Orchestrates chat flow, bridges views and agent
services/chat/ The chat turn machinery around it: chat_user.py (the CurrentChatUser boundary value), conversation_access.py (ownership-door conversation access), history.py (the conversation history by token budget, and its stored summary), cross_turn_notes.py (prior searches / connector reads / action decisions re-presented per turn), turn_reporting.py (audit + trace linkage), assistant_turn_recorder.py, conversation_folder_service.py
services/agent/agent_core.py The ReAct reasoning loop and its factories
services/agent/prompt_context.py Initial-message assembly: folder resolution, attachment cadence block, the cached/volatile system-message split
services/agent/tool_wiring.py Per-run context wiring and the per-iteration connector-tool rebuild
services/agent/tool_execution.py Traced, metered execution of one tool call
services/agent/messages.py The model's history: one Pydantic shape per role (ChatMessage), the builders the agent loop and the skill generator append with, to_chat_completions (the one boundary that turns the history into wire dicts) and count_message_tokens (the one counter of its tokens)
services/agent/observation_rendering.py The observation boundary: citation stamp + render, and the per-result budget cap
services/agent/source_attribution.py Sources-panel rows from a turn's cited refs
services/agent/search_audit.py The per-turn search-audit system message
services/agent/lazy_referral.py Observability-only lazy-referral detection
services/agent/connector_previews.py Capped connector-read previews for cross-turn recall
services/agent/tool_narration.py narrate and summarize_round, the one home of the words a person reads for each row and each round line
services/agent/tools/search.py SearchTool - the registered search tool and its scope selector
services/agent/tools/search_knowledge.py The composed module behind scope="campus": the institutional corpus, with folder auto-routing
services/agent/tools/search_attachments.py The composed module behind scope="my_context": the conversation's own files and folders
services/agent/tools/find_entity.py FindEntityTool - resolve a name, address one entity, or browse a type
services/agent/tools/list_entities.py The browse query, the typed row per entity type, and the shared page ceiling behind that browse
services/agent/tools/get_attachments.py Enumerate the conversation's attached files and pinned folders
services/agent/tools/utilities.py GetCurrentDateTool
services/agent/tools/connector_tool_factory.py Builds one typed tool per connector action, named {slug}__{action}
services/agent/tools/base.py BaseTool, ToolRegistry, create_default_registry()
services/agent/tools/presentation.py Knowledge-layer vocabulary: entity_label, entity_entry_phrase, the entity-count caveat strings
services/agent/tools/describe_knowledge.py Corpus inventory tool; its format_for_llm renders caveated entity counts
services/agent/middleware/ Token management, tool-result budget, compaction
services/folder_routing.py The single home for folder-description similarity (used by search routing and the admin matrix)
services/agent/events.py Streaming event types for real-time UI
prompts/agent_system_prompt.py Agent system prompt template
prompts/prompt_builder.py Reads AppConfig, formats the template

Model Configuration

The agent's model and reasoning effort are rows, not a setting: the deployment's active model, and an optional override per person, both set in the Models tool. AgentModelChoiceService in services/agent/agent_model_choice.py reads the person's override, else the active model, at the start of every turn, so a change applies from the next turn. A guest always runs on the active model. The choice must be a chat model in the model registry, because the agent resolves its context window from the entry's chat spec when it is built. How the choice is set and checked is in model-cost-tracking.md. The registered tool surface is search / find_entity / read_file plus the inventory and connector tools - exactly the set create_default_registry() builds (pinned by the exact-set contract test in tests/test_agent_core.py). find_entity's content_type is a closed set of the twelve typed tags, declared once as TypedEntityTag in models/document.py, so the model sees an enum in the tool schema and a value outside the set is refused at validation time. An entity's display name comes from entity_display_name in models/document.py, which reads the satellite field named in ENTITY_LABEL_FIELDS; every tool title and the admin's own label go through it, so no reader keeps a name chain of its own. A find_entity browse row is a typed EntityRow per entity type, declared in services/agent/tools/list_entities.py, whose label carries entity_display_name off the wire - so the title on a compact browse row is the same name every other surface shows. The five former "reasoning support" meta-tools were removed after an evidence-gated keep/cull decision (issue #24: the agent never invoked them, and the loop's own prompt-taught behavior covers every shape they claimed). Per-model request behavior (Responses or chat-completions endpoint, reasoning effort, temperature, and whether parallel tool calls are switched off) is the ChatRequestSpec on the model's entry in services/llm_infra/model_registry.py (see model-cost-tracking.md). The spec's endpoint picks the API for every LLMWithTools request, including generate_simple and generate_simple_with_usage, the no-tools fallback a turn uses when it hits the iteration limit. The agent loop and the skill generator hold the model's history as the typed shapes in services/agent/messages.py, never as dicts, so a misspelt field fails ty check and raises when the message is built. The history becomes wire dicts at two boundaries only: to_chat_completions for chat-completions and responses_adapter.messages_to_responses_input for the Responses API. On the Responses path, the adapter reads each message item's phase label and sends it back on the assistant history, from the agent loop and from the skill generator, and to_chat_completions strips it from every chat-completions request. Claude models run on chat completions, because LiteLLM has no Anthropic path through the Responses API, and LiteLLM turns the effort into adaptive thinking. With thinking on, Anthropic refuses a tool-call continuation whose assistant message lost the thinking that asked for the calls. So StreamComplete.thinking_blocks carries the response's signed blocks, the agent loop and the skill generator put them on the assistant message that carries the calls (AssistantMessage.thinking_blocks), and to_chat_completions sends them back as thinking_blocks. The blocks live only in the turn's history: the span events and the stored conversation never hold them, and the next turn starts from the text answer. A model whose spec names chat_completions never sends a label, so its frames carry kind: null and the server and the browser fall back to the NoteEvent and the length-or-turn-over rule (Blocks, notes and the answer, below): switching the agent's model changes only when the ink is known, never what is drawn, and never how the finished turn reads. The full-size gpt-5.x family runs at medium reasoning effort: at low the model trims an enumeration it already holds and stops at a listing when the question needs the pages read, for about one extra second on a simple browse answer and ten on a multi-step one. Model swaps are gated on the A/B eval harness: run evals/offline/model_ab_eval.py (see evals/README.md) and change the default only on parity or better.

The Registered Tools

Every run starts from create_default_registry() (services/agent/tools/base.py), which builds each knowledge-reading tool already bound to the run's principal. Model Configuration above names the set; this section says what each tool does.

search - hybrid retrieval over indexed chunks, with a scope selector

search(query, scope, top_k) is the content-lookup tool: hybrid semantic plus full-text retrieval over indexed chunks, returning passages that each carry a source_file_id the agent can follow into read_file. SearchTool (services/agent/tools/search.py) composes two internal modules behind one scope argument, and clamps top_k to 20 whatever the model asks for.

scope="campus", the default, searches everything the institution has indexed - scraped pages, curated knowledge folders, and uploaded documents. Each call routes to the folders that best match the query and searches the full index only when none of them clears the threshold (Knowledge Routing below). A pinned folder's indexed content is already part of campus; there is no separate folder-search tool.

scope="my_context" narrows to the material the user brought into this conversation: the files they uploaded and the folders they attached. It is a narrowing of the same corpus rather than a second one, and it covers every attachment in the conversation - the tool schema offers no per-file scope, so the agent aims at one file by naming it in the query.

Both scopes reach chunks only through KnowledgePolicy, at the vector store's access pass. Scoping to the conversation is a separate, additive step: the my_context path resolves the files the principal linked into the conversation with ConversationAttachment.objects.attached_file_ids and filters parent_file_id on that list, so a conversation filter can only shrink what the policy granted. See knowledge-access-control.md, "Conversation scoping is not access".

agent_core.py wires the run's context into the tool before each run through three builders - with_conversation_id(id), with_folder_ids(ids, names) and with_attachment_filenames(filenames) - and all three feed the my_context path only, because the campus path discovers its folders from the query instead. The folder ids are the standing set, every folder ever added to the conversation rather than the current message's selection: the composer's folder picker adds to that set, and ChatService reads the whole set back on every turn, so a turn that sends no folders still gets it.

find_entity - typed entities, in three modes

find_entity (services/agent/tools/find_entity.py) is the entity door, and which of its three modes runs is decided by which arguments arrive. A description resolves a name, code or title to one entity's full payload, or to a candidate set when several match with similar confidence. An entity_id or a url fetches that exact entity. An empty description browses the type: sort_by, since/until, category and tags_any filter the browse, and top_k with offset page it.

The browse query itself, and the typed row each entity type returns, live in services/agent/tools/list_entities.py, which find_entity composes. BROWSE_TOP_K_MAX there is the page ceiling both browse input models take their bound and their model-facing top_k description from. A since/until bound applies to the type's own date - article published_at, event starts_at, scholarship deadline, policy effective_from - and is refused on a type with no date of its own rather than ignored.

read_file - one file end to end

read_file(source_file_id, start_chunk?, max_chars?) returns a file's text in chunk order, a page at a time, with a next_chunk cursor to call back with until is_complete. The source_file_id it takes comes from a search hit, a find_entity payload, or get_attachments, which is what makes the three retrieval tools one surface rather than three. Its result also carries name, the entity's display name or the file's display_name, which the trace row reads as "Read """; title stays the model's summary line and the citation's title.

The inventory tools

describe_knowledge(content_type?, folder_slug?) reports how many sources are indexed and how many entities of each type, always caveated as index statistics (Entity counts are index statistics, below). get_attachments() enumerates the files and folders attached to this conversation, each file with its source_file_id. list_folder_files(folder_id, offset?) pages through the files inside one knowledge folder, which is how the agent drills into a folder listing that came back truncated. get_current_date() returns today's date, which is what a window such as "the last 30 days" is computed from. use_skill(slug) loads the full instructions of a skill listed in the volatile block's ## Skills section; it reads only the catalog built for this run and is dropped from the toolset when that catalog is empty. agent-skills.md owns the skills themselves.

The connector bootstrap

list_my_connections() returns the user's connections, each with its agent_hint and its catalog of actions, and load_connector_actions(slug=...) registers one connector's actions as typed tools available on the next iteration. services/agent/tools/connector_tool_factory.py builds those per-action tools; connectors-philosophy.md owns the progressive-disclosure design behind them. A connector loaded or used in one of the last three completed turns of the conversation is in the next turn's tools from its first model call, so the model calls it without loading it again. A stopped or errored turn is not a completed one and does not count, and a load the loader refused counts as a use. Each such connector goes through the loader's own checks in resolve_loadable_actions first, and one the person may no longer load is left out. The connected-services block of the prompt also carries the factual-answer rule: a fact about the person, such as what they owe, is answered with a value a connector returned, never one the model works out by adding, netting or projecting, and when no record carries it the model says so, shows the returned rows that bear on it and names the office that holds the official figure. That office comes from a search, as the rule that campus facts are never answered from memory requires, and when no source names one the model says which kind of office to ask. Picking, filtering, sorting and comparing returned fields is reading and is allowed, and a skill that teaches a calculation is the one exception.

The artifact tools

load_artifact_tools() is the bootstrap, and the iteration after it runs carries create_artifact(title, content, source_refs?, change_summary?) and update_artifact(artifact_id, content, change_summary, title?, source_refs?), which make and revise a document that lives beside the chat. Only the loader joins the registry, and only when create_default_registry(principal, artifacts=True) is asked for it, which ChatService does when the person's role has cc_artifacts on; the same switch selects the prompt variant that carries the ## Artifacts section. artifacts.md owns the design, the kind contract and the panel.

The guest registry

create_guest_registry() (services/agent/tools/base.py) builds the public-visitor toolset: the same search, find_entity, read_file, describe_knowledge, list_folder_files, get_current_date and use_skill, without get_attachments and without the two connector tools. Those three are absent rather than disabled, so no prompt can reach an upload or a stored credential. use_skill is safe on the public surface because a guest's catalog holds only the skills an admin marked include_guests.

When the agent uses which tool

Scenario Call
Content lookup, no attachments ("what does the policy say about X") search(query="...")
A file attached this turn ("what is this about", "summarise this") search(query="...", scope="my_context"), naming the file in the query
"How does this compare to what I sent earlier?" search(query="...", scope="my_context") - one scope already covers every attachment
The attachments do not answer it search(query="...") on the campus scope, as a second call
Read a whole file end to end read_file(source_file_id=...)
"Latest news", "what happened in July" find_entity(content_type="article", since=..., until=..., sort_by="date_desc")
A named entity ("Dr. Smith", "CS 201") find_entity(content_type=..., description="...")
"How many documents do you have indexed" describe_knowledge()
"What did I attach here" get_attachments()
User-specific data from a connected service load_connector_actions(slug=...), then the action tool it registers

Enumeration questions

A period or recency question ("what's the latest", "what happened in July", "events this week") asks for the set of items in a window, not a sample of it. The answer lists every row the browse returned inside that window, once each with its date, stated and cited from the browse rows themselves rather than from one fetch per row.

The open news window is the last 30 days, browsed with no category filter, because an announcement or a press release is news to the person asking. The answer is whatever that window holds, even one or two items, with the count said plainly. The agent never widens the window on its own: an empty window is said plainly with an offer of older stories, and the user asks for them if they want them.

Inside a bounded window the opposite rule holds, and the window is answered to completeness. That is what the page-full sentence in FindEntityTool._browse_summary is for: a browse whose query filled the page tells the model the offset the rest starts at, so a July holding thirty stories is listed whole across two pages. The rule therefore has two homes - the enumeration rule in prompts/agent_system_prompt.py, under Response Discipline, and that header sentence - and each is pinned by test: the prompt wording by test_period_questions_enumerate_not_sample, the header by the browse tests in tests/test_agent_find_entity_tool.py.

Attachment Handling

File Attachments

Files are conversation-scoped for storage (not per-message). The ConversationAttachment model tracks each file through the statuses uploading, extracting, available, ready, failed and removing; document-ingestion-state-machine.md owns which transitions are legal and who writes them. Once a file is ready, it stays available for all subsequent messages in that conversation.

A scope="my_context" search reads the whole conversation's attachments. "what is this about" reaches the file the user just attached because the agent names it in the query, using the cadence context's "added just now" marker. The uploaded_with_message FK is display only, not a scope boundary.

Uploading before the first message

A user can attach a file to a chat that does not exist yet, so the presign call may carry no conversation_id. The row is then created with an uploaded_by and no conversation, and the uploader is the only thing that can answer "may this user touch this row": ChatAttachmentService authorizes confirm_upload, get_attachment_status and remove_attachment on uploaded_by first and falls back to the conversation's owner only once there is one. Sending the first message creates the conversation, and link_pending_attachments_to_conversation attaches every pending row the sender uploaded; the database stamps SourceFile.conversation from the link in the same statement. A row with neither a conversation nor an uploader is reachable by nobody - the authorization ladder refuses it and the adoption query filters by uploader - so a check constraint refuses to store one.

get_attachments

The tool that answers "what did I attach here" takes a principal, the same as every other retrieval tool, and it is bound at construction by create_default_registry. The conversation id and the folder ids arrive separately, through the two builders tool_wiring calls before each run.

A file row is listed only when ConversationAttachment.objects.attached_file_ids counts the link for that principal: they uploaded it, or the link predates the uploaded_by field and the conversation is theirs. That is the same resolver a scope="my_context" search narrows with, so the two views of the conversation's attachments agree on which links belong to the asker. Wiring the tool with a conversation the principal cannot own therefore lists no files rather than listing someone else's.

Folder rows get no second check, and that is deliberate. The folder ids reach the tool only through get_folder_details, which keeps just the ids in KnowledgeFolderService.aget_accessible_folder_ids(principal) for the same principal, so by the time the tool sees an id it is already a policy-checked fact. Re-checking it here would make the tool a second home for the folder authority.

The tool declares principal: EndUser rather than the EndUser | Anonymous union the guest-facing tools use, because create_guest_registry never registers it - a guest cannot reach an upload at all.

Folder Attachments

Folder selections are ephemeral - sent as query parameters per message, not persisted. The user selects which KnowledgeFolder IDs to scope to for each message.

Recency Context

When files are attached with the current message, the system prompt labels them distinctly:

## User Attachments

### Files
- report.pdf **<-- attached with this message**
- syllabus.docx

### Folders
- Financial Aid (id: 42)

This tells the agent which file is the current-turn subject. A scope="my_context" search covers every attachment in the conversation, so generic references ("this", "what is this about", "summarise this") reach the just-attached file through the agent naming it in the query. A historical or comparative question needs no widening step.

Data flow for recency: 1. Frontend sends attachment_ids (IDs of files uploaded with this message) in the POST /api/chat/stream body 2. ChatAttachmentService.get_filenames_by_ids() resolves IDs → display names, scoped to the turn's conversation - a claimed id the conversation does not own resolves to nothing 3. chat/chat_service.py passes attachment_filenames (all) and new_attachment_filenames (this message) to agent.run() 4. prompt_context.build_attachment_context() labels the current-turn file(s)

Connector Files Loaded into the Chat

A connector action (canvas__load_course_file_into_chat) can pull an upstream file into the conversation. Unlike a hand upload, the processed copy is shared: one SourceFile with access_scope="connector" per upstream file version, linked into conversations by ConversationAttachment rows. The link is the access grant - KnowledgePolicy.chunk_q grants a user the connector-scoped chunks of files they hold a live link to - and the my_context scope includes the chunks of files linked into the current conversation. Corpus searches exclude connector-file chunks exactly like attachment chunks; the shared chunks carry metadata.provenance.source_type = "connector_file" plus the connector identity under metadata.provenance.connector_file, and never any conversation or user key. These files show in the Context panel only, never as message chips. See connectors.md, "Chat-Attachment File Bridge".

Document Indexing for Attachments

File attachments use lightweight metadata (filename as title, first 200 chars as summary) instead of LLM-generated metadata. This speeds up processing from ~12-18s to ~5s per file.

An attachment chunk records the job that wrote it under metadata.provenance: the kind as source_type, and the conversation, uploader and link row under attachment. That block is provenance, not the isolation mechanism - retrieval scopes by the ConversationAttachment link rows and decides access by KnowledgePolicy, both reading columns rather than metadata.

The search labels are read from the parent file, not from the chunk, and both are rendered by the modules search composes: the my_context one (services/agent/tools/search_attachments.py) renders File: <display_name> or Folder: <name> - File: <display_name> from the parent_display_name and parent_folder_id that ride on each SearchResult, and the campus one (services/agent/tools/search_knowledge.py) falls back to parent_source_url for a hit's link. A renamed file or folder therefore relabels its chunks with no reingest.

Knowledge Routing

Folder routing happens inside the search tool at search time, not before the loop. A scope="campus" call reaches it through SearchTool (services/agent/tools/search.py), whose campus module (services/agent/tools/search_knowledge.py) runs the folder-discovery step: services/folder_routing.score_folders, one pgvector CosineDistance query over KnowledgeFolder.description_embedding (threshold 0.50, at most 5 folders). The same function feeds the admin Folder Routing matrix (services/observability/folder_discoverability.py), so the dashboard shows the live path's scores by construction. A pre-loop KnowledgeRoutingMiddleware was built but never wired into the loop and has been deleted.

The vectors the query reads come from KnowledgeFolder.save, which embeds the description as it saves the folder. It records the model that produced the vector in description_embedding_model beside it. The check constraint knowledgefolder_embedding_model_names_its_vector holds the two columns together: a vector always names its model, and a model name never stands without a vector. A save that leaves the description empty or blank clears both, so the query never scores a folder against a description that was removed. A save whose embedding fails is refused with a 503, rather than writing a folder the query would never see. A row that lost its vector earlier is embedded again on its next save, even one that only renames it.

System Prompt Design

The system prompt uses a two-message pattern for caching efficiency:

  1. Static system message - the agent prompt template (cached across requests, ~5k tokens saved on cache hits)
  2. Dynamic system message - per-request context:
  3. User identity (## User Identity: preferred name, role names, department - signed-in only)
  4. Currently Connected Services and the Knowledge Inventory (signed-in only)
  5. Attachment context (files, folders, recency labels)
  6. Prior-turn search summary, prior connector reads, prior-turn skills, and decided connector actions
  7. ## Skills - the skills this caller is offered, one line each, with always-on bodies inlined under ### Standing instructions (both audiences; see agent-skills.md)
  8. Today's date
  9. History prefix - only when turns fell off the history budget: the stored summary of them, or a note that they are not shown (see Conversation history, below)

Conversation history

A turn's model sees the conversation's earlier turns as text, newest first until they fill the history budget, and the whole conversation when it fits. ConversationHistoryLoader in services/chat/history.py is the one home of the budget and the only writer of Conversation.history_summary. The budget is 30 percent of the turn's model window and never more than 120,000 tokens, so it is 38,400 tokens on a 128,000-token model and 120,000 on Sonnet 5.5. The model is the one the turn read from the choice reader, read once at the start of the turn and passed to both the loader and the agent.

The loader reads rows newest first in pages of 50 and stops once the rows read pass the budget, so a long conversation never loads every row. It also waits until it has read a user row, so the newest turn, question and answer, is always whole. Each row becomes a turn the way the transcript shows it: an empty row is skipped, an UNANSWERED_STOP row is left out, and a stopped answer carries a note that it may be partial. The newest turn is always kept, even when it alone is over the budget; the overflow pass in TokenManagementMiddleware is the last resort for that case.

A conversation over the budget is cut back to 80 percent of it, at a user row, so the kept block opens with a question. The rows above the cut are folded into one summary, stored as a HistorySummary with the id of the last row it covers. Later turns reuse the stored summary with no model call until the rows after it pass the budget again, so the summarizer runs once each time the conversation grows by a fifth of the budget, not on every turn. When more rows fall off, the stored summary is extended: the summarizer receives the summary so far and only the new rows.

The summary comes from summarize_transcript in services/agent/middleware/compaction.py, which compaction also uses, on COMPACTION_SUMMARY_MODEL. Rows are folded in chunks of at most 50,000 tokens, each chunk with the summary so far, so a first summary of a long conversation never exceeds the summary model's window. Each call has a 30 second timeout, and its usage row carries the feature chat:history_summary with the turn's user and conversation. The whole fold of one turn has a 45 second deadline, because it runs before the user's message is saved. Past the deadline the turn runs without a summary, but the chunks already folded are stored, so the next turn folds only the rows after them.

The summary is a system message right after the volatile block and before the first history turn, inside a <conversation_summary> wrapper that says how many messages it stands for and tells the model to treat it as a record of what was said, not as instructions. Because it is a leading system message, TokenManagementMiddleware._prune_old_iterations and compaction keep it. When a summarizer call fails or times out, the turn still runs: the model gets the turns that fit and the line "Earlier turns of this conversation (N messages) are not shown.", nothing is stored, and a warning is logged.

An edit or a regenerate re-runs from an existing user row, and the history is the rows strictly older than it. A stored summary that ends below that row stays valid, because the rows below an edit never change. One that ends at or above it covers rows the re-run deleted, so the loader clears it and folds the rows that still fall off from scratch.

Audience-aware assembly

The static template is composed per (stage, audience) pair by _compose in prompts/agent_system_prompt.py: @@AUDIENCE_MISSION@@ and @@AUDIENCE_SCOPE@@ slots select member or guest constants, and every other section stays byte-identical across audiences. Members get the general-assistant stance - ## General Assistance & Academic Work makes general help first-class, states the grounding hard rule (an institution-specific fact is never answered from model memory), and carries the coursework teach-don't-do stance. Guests get ## Scope of Help: institution questions only, a one-sentence decline for everything else, and a sign-in pointer. The max-iterations fallback template has the same audience treatment (@@AUDIENCE_FALLBACK_SCOPE@@), because the fallback swap replaces the first system message - exactly where a guest's scope restriction lives. build_agent_system_prompt(stage, audience) in prompts/prompt_builder.py resolves all four combinations and validates a per-audience required-sections list.

User identity in the dynamic context

build_user_identity_section(user, role_slugs) (prompts/prompt_builder.py) renders the ## User Identity block from display_name(user) (apps/auth/services/identity_service.py - preferred name, official name fallback), Role.display_name values resolved from the principal's role slugs, and UserProfile.department. Absent fields are omitted; a user with nothing known renders no section at all. The block closes with two rules. A question about the person's own situation is answered from their connected records, read before answering, never from the block's fields alone. A person named by the user's name in the block, in full or by first name, is the user unless the user marks another person, such as "my friend"; then it is another person, whom the model answers for only through a connected service that covers other people, such as a course roster. Otherwise the model says it can answer only for the user and names the office that can help, from a search, or the kind of office to ask when no source names one. The block deliberately excludes student_id, external_id, and every other FERPA-adjacent identifier - the dynamic context is stored verbatim in the trace store (see security-and-compliance.md) - and a contract test pins the exclusion.

Entity counts are index statistics

Entity counts describe what extraction indexed - duplicates and cross-listings included - never how many courses, programs, or anything else the institution offers. The contract is enforced at the data layer first. The one caveat string lives in services/agent/tools/presentation.py as ENTITY_COUNT_CAVEAT, and both surfaces that render corpus counts attach it to the same text as the numbers - the Knowledge Inventory block in prompts/prompt_builder.py and describe_knowledge's format_for_llm - so the model never receives a bare "2630 courses" it could quote. Result-set summaries (find_entity, list_entities) use the same "N course entries" phrasing so a page of results cannot read as a total either. The prompt half is _ENTITY_COUNT_RULE in prompts/agent_system_prompt.py, a stage-neutral constant spliced into both the reasoning-loop and fallback templates. The routing table splits "how many do YOU have indexed" (describe_knowledge) from "how many does the university OFFER" - the latter answered from an authoritative source: a connected service whose capability covers it (a student information system, a registrar API) or a published statement found via search.

Tool descriptions self-describe; the prompt routes

A tool's description states what the tool does, its inputs, and the properties of its output - including caveats about its own data - and nothing about sibling tools. Cross-tool routing ("for X use Y", "call A before B") lives only in the system prompt's Routing Framework, Tool Catalogue, and question-shape table, so routing policy has one home and tool descriptions stay true wherever the tool is mounted (default registry, guest registry, future surfaces). An institutional total is answered from a published figure with a citation, or the agent says the indexed sources do not state one and offers to narrow - the count-trap probes in evals/data/reasoning_probe_questions.json exercise exactly this.

Streaming Events

A chat turn streams as typed events, and the browser draws them as a timeline: a note, the folded line of the round of tool calls that followed it, and so on, with the answer last. One reducer builds that timeline from a live turn's frames and from a saved turn's trace, so a reloaded turn reads as it did live.

Event Purpose
TextEvent Text the model is writing, in the block block_id names. A block is one model response's text, numbered from 1 in the turn. delta True extends the block, and delta False replaces it, as the step-limit answer does in a block of its own. TextEvent.applied_to is the one home of that rule, and turnItems.ts mirrors it. kind is note, answer or null: what the block is, from the model's phase label on its message item or, for the step-limit answer, the loop itself, and null when neither says
NoteEvent Text the model wrote for the person before a round of tool calls. It names the block it streamed as, which every consumer then treats as a note rather than answer text. Its wire type is thinking and its field is thought, because stored traces carry both
ToolCallEvent Tool invocation with name and arguments, plus the row's label, kind, service, subject and the action's verb
ToolResultEvent Tool result with the finished label, the outcome count, the found count and noun, up to five record names, the failure reason, held and the call's duration_ms; its data_preview never streams
RoundEvent The folded line for a round of tool calls: its step, its label and its duration_ms. Sent after each call and each result, and never stored
ReasoningEvent The title of the part of the model's reasoning summary it is writing now. Live only, and only when AGENT_REASONING_SUMMARY_ENABLED is on
ActionConfirmationEvent A connector write held for the person's decision, with the connector's display name and the model's proposal_reason
ArtifactStartEvent An artifact tool call has begun streaming its arguments; the panel opens on a skeleton
ArtifactEndEvent The artifact version saved (id, kind, title, version, change summary); never the body
SourcesEvent The sources the finished answer cited (emitted once, only when non-empty)
ConversationEvent The conversation created or resumed for this turn, so the client can address it
MessageSavedEvent A message row was persisted, with its id and role
CompletedDoneEvent The agent answered, with its step count, tools and worked_ms; status is completed or max_iterations
ErroredDoneEvent The turn failed; status is error and kind says how (rate_limit, timeout or unexpected)
ErrorEvent The turn failed; carries the user-safe sentence alone

Events are streamed as SSE-formatted strings through chat/chat_service.py to the frontend, where web/src/lib/streaming/liveTurns.ts reads them and web/src/lib/streaming/turnItems.ts folds them into the timeline. The read loop lives in that store rather than in a component, so a turn keeps arriving when the person leaves the chat it was asked in; useChatStream.ts is the hook that starts turns and reads the one belonging to the conversation on screen.

Blocks, notes and the answer

AgentCore decides which text is a note, and a model that labels its text tells it before the first word. A Responses model labels each message item it writes with a phase: commentary for the text before a round of tool calls, final_answer for the answer. The Responses adapter yields that label as a TextBlockStart before the item's first text. The label the model last gave before the block's first text names the response's one block, and the loop stamps it on every TextEvent of that block, as kind note or answer. A label after the block opened changes nothing, and the step-limit answer is always answer. The text of a model response that also asks for tool calls is a note, and when that response's first tool call arrives, the loop sends a NoteEvent naming the block before the round's first ToolCallEvent. That NoteEvent is still what stores the note, so it wins when a call follows a block labelled answer: the model went on working, and the block becomes a note. A model that sends no label, or one the adapter does not know, gives frames with kind null, and the NoteEvent alone names the note. The block still open when the turn completes is the answer, so the answer equals agent_state.final_response. A note block still open when the turn is stopped or fails stays a note, on the server and in the browser alike. The system prompt asks the model for one short plain sentence before each round, saying what the round is for, and for nothing before the answer except the answer.

ChatService folds the blocks on the server. Message.text holds the answer alone, so copy, the history sent to the model and the cross-turn notes hold the answer alone, and the notes live only in the stored trace. The assistant row is created at the first text, note, tool call or saved artifact. The checkpointed text is the open block's, except that a block whose first frame says note checkpoints an empty text, and a block a NoteEvent names is set back to empty. A turn that completes on a note block stores it as the answer. A turn stopped or failed while a note block is open stores that block in the trace as a note, with its block_id, and an empty text. A turn stopped or failed during a round stores an empty text with was_interrupted true, and UNANSWERED_STOP beside the Message model is the one filter the model history, analytics and the active artifact use to skip such a row.

worked_ms is the time from the turn's start to the answer's first text. ChatService sends it on the done event and stores it in Message.worked_ms, a column rather than a trace key, so a rolled-back release still parses the trace. A turn with no answer has none.

Stream versions

A client states the protocol it reads with the stream_version query parameter, which StreamQuery in campus_core/api/streaming.py parses for all three stream endpoints. round and reasoning frames go only to a client that stated version 2 or higher, so a tab loaded before they existed never meets one. The SPA, the guest page and the widget state version 2, and a server that predates the parameter ignores the query string on a POST. The browser's live switch skips a frame type it does not know, so a server that adds one cannot end a turn in an older tab.

An answer arrives a token at a time, so every write that can be the last one of an unfinished turn - create_pending, checkpoint and the teardown flush - saves partial_answer_text (services/chat/answer_text.py) rather than the raw text. It drops a tail the stream was cut inside: a tag the model was opening, an entity, or a citation ref, any of which a browser swallows together with whatever follows it. finalize does not trim, because a completed answer is whole and a trailing < there is the model's own text. The browser repeats the same trim in web/src/lib/render/unfinishedMarkup.ts, for live text and for a saved row marked interrupted.

The trace rows

The server writes every word a person reads in the trace, and the browser lays it out. narrate(tool_name, arguments, result, names) in services/agent/tool_narration.py returns a ToolNarration with a label, a kind, a service, a verb, a subject, an outcome, a found count, names and a held flag. Called without a result it gives the in-progress sentence, such as "Searching the knowledge base for "housing deadlines"", and with one it gives the finished or the failed sentence. The loop puts the in-progress words on the ToolCallEvent and the finished words on the ToolResultEvent.

kind is the ToolKind choice that picks the row's icon: search_knowledge, search_attachments, lookup, file, connector, artifact or other. file is a file read, and get_attachments and list_folder_files are other, because listing files reads none and a round line says "read a file" for every file row. service is the connector's display name on a connector row and empty on every other row, the bootstrap tools included. A connector row is worded from the action's declared verb and label, or the wording the call's arguments pick for an action that does a different job by them (see connectors.md): "Listing your open incidents in ServiceNow", "Listed your open incidents in ServiceNow" or "Couldn't list your open incidents in ServiceNow", and a search quotes its query, as in "Searched SharePoint for "policy documents"". The call event carries the verb, so a round's line read back from a stored trace is worded the same as live, and a row stored before actions declared a verb keeps its "Asked ServiceNow to ..." sentence. A write that asks the person first sends nothing while it runs, so its in-progress row reads "Asking for your approval to create an incident", decided from the action's declared permission through find_action_schema with no database read. A held write reads "Asked for your approval to" and the card's title, such as "Asked for your approval to create an incident", and carries held true, so the browser tells it from a write that ran without matching words. A long query is cut to 60 characters with a single "…" inside its quotes. A find_entity call by id or address names nothing in its arguments, so its finished row names the entry's title from the result, as in Looked up "APA accreditation".

subject names the record a connector row acts on, so ten rows of the same SharePoint action read apart. Each run keeps a RunNames book: after every successful connector result, the loop records the name, title, subject or display name of each record it returned, by that record's id, and the record it was listed in, the last argument of that read the book already knew. A later connector row whose argument is an id in the book carries the name after the names of the records it lives in, at most three, each cut at 60 characters and joined with " › ", as in "CampusCore › Documents", and the browser shows it muted after the label. When several arguments are in the book, the last one names the row, because connectors put the container first, such as a site id before a list id. The result event works the name out again from the book, and the browser shows the result's value once it arrives. Only str ids are kept, because an int id such as Canvas's is unique only within one kind of record.

outcome is the short count shown on the right of a successful search or connector read. A knowledge or attachment search counts the distinct documents behind its citations by source_key, as "1 document" or "3 documents". A connector read that declares a result noun counts what it returned through result_item_count, which knows a plain list and a page of results, in that noun, as "3 files" or "1 library". Either with nothing found reads "No" and the plural, as "No documents" or "No files", and a noun of two things reads "or" there, as "No files or folders". A find_entity, resolve_entity or list_entities call that matched nothing reads "No results", and every other row has an empty outcome. The count rides on the result event as found, and a round of listings sums it by noun across calls of one action, as "Found 6 libraries and 3 lists in SharePoint". When two different actions count the same noun, as all sites and the sites a person follows, their records can overlap, so the larger count stands and the other listing is left out, because the line already says what it listed: "Found 5 sites in SharePoint". A round whose connector steps all ask one service names it once: "Searched SharePoint twice and found 5 sites". While any call of a round waits for its result, its line names every kind of step in the running form with no counts, as "Searching SharePoint and listing sites", and a round stopped part way keeps that line on reload. When the action's noun declares shows_names, the result event also carries names, the names of its first five records, each cut at 60 characters, and the browser shows the count as a button that opens them under the row. A guest receives the count and never the names.

On the result event, summary is the model's result line on success and empty on failure, and reason is the person's failure line. A connector result's summary is written by _build_action_summary in connector_tool_factory.py, and it names what came back, never "Done.". A set of records reads as its count and the noun the action declares, as "5 sites." or "No pages.", and adds "and more on the next page" when the result carries a next page token; an empty page with a next page token reads "No {plural} on this page, and more on the next page." A single record reads as its name, title or subject, then its number, then its status, and otherwise as its class name in words, as "One message detail.". A countable result from an action that declares no noun raises TypeError, and the connector contract test keeps every such action declaring one. A failed tool that sets no reason gets the loop's default, "This step could not be completed.", and a tool that raises reads "This step hit an unexpected error.", with the exception kept in error for the model and the span. The connector tool factory writes a reason per refusal, such as "Outlook Mail needs to be reconnected in Settings.", and never puts exception text in it. A classified connector failure's reason comes from person_reason in connectors/services/failure_reasons.py, which the approval path shares (see connectors.md). The reason is per cause: each subclass of ConnectorInvalidRequestError has its own sentence, so a site id passed as a library reads "CampusCore could not build this request, so nothing was sent to SharePoint." and a folder loaded as a file reads "That item on SharePoint is not the kind, or in the state, this action needs.". A labeled row never shows the tool's summary, so coaching written for the model, such as get_attachments's, reaches only the model.

Round lines

A round is the tool calls of one step, and its folded line is worded by summarize_round in tool_narration.py, so the browser writes no sentence about a turn. While any call of the round waits for its result, the line names every kind of step in the running form with no counts, as "Searching SharePoint and listing sites". Once every result is in, it names each kind of step that succeeded with its counts, as "Searched the knowledge base twice and found 6 libraries and 3 lists in SharePoint", and counts failed steps in a suffix, as in "Searched the knowledge base - one step failed". A count of steps is said in words up to ten, as in "read three files", and in numerals above that, while a found count is always in numerals. The round's duration_ms is its slowest row's, because a round's calls run together.

AgentCore sends a RoundEvent after each call and each result, and TranscriptTrace works the same events out again on read, so a round stopped part way reads the same live and after a reload. A round whose rows carry no words, from a trace stored before rows had them, has an empty label, and the browser names it from its last labeled row or "Thought". A row whose call has not returned shows a pulsing ellipsis only while the turn streams, and once the turn is over such a row was stopped: its label stays in its running words, muted, and "Stopped" sits where a count would.

The timeline in the browser

TurnTimeline.tsx draws the items in the order they happened: notes, round lines and approval cards, with the answer after them. While the turn runs and no text is arriving, a live line shows "Thinking" or the latest reasoning title, with the time since the turn started; while a round runs, its own line stands in for it. Once the open text is known to be the answer, the whole timeline folds into one line above the answer, such as "Worked for 9.4s · 7 steps", which opens to the timeline with each round still folded. openTextIsAnswer in turnItems.ts is that test, and it reads the kind of the block's first frame first: an answer block is the answer at once, and a note block only when the turn completes on it. It takes the turn's TurnOutcome: streaming, completed, or interrupted for a turn that was stopped or failed, which turnOutcome in liveTurns.ts reads from the stream's phase; a saved turn passes completed, because its open block is the saved answer. A block with no kind falls back to the length and turn-over test: it is the answer once the turn is over or its text is longer than NOTE_MAX_CHARS (300). A turn with no answer and a turn waiting on an approval keep the timeline open, and an approval card is never inside the fold. So a turn stopped during a note keeps its timeline open with the note in grey and "Response was stopped." under it while it is live; after a reload it shows the same open timeline with "This answer is incomplete." and Regenerate, as every turn stopped before its answer does. Under an opened fold, the notes and round lines sit indented behind a rule down their left side, below the fold line's chevron, so they read apart from the answer. A note is written in one grey in both themes, the round lines' light-mode slate-500, one step back from the answer's body. The block the model is still writing is drawn in the ink its kind names from its first frame: the answer's ink for an answer block, and that grey for a note block unless the turn completes on it. Only a block with no kind is drawn grey until proven the answer, because the server then names a note only at the tool call that follows it; it takes the answer's ink, fading over 300ms, once the same test the fold uses says it is the answer. The fold line's chevron starts on the answer's left edge, and a round line's chevron on the notes' left edge. An approval card breaks the rule and sits at full width, and a turn with no fold line draws no rule.

The persisted trace

The note, tool call, tool result and action confirmation events of a turn, with the done event's counts and the cited sources, are stored on the assistant row as Message.agent_metadata. The stored shape has one home, PersistedTurnTrace in services/chat/turn_trace.py, built from the SSE classes above so the wire and the stored forms cannot drift; the one difference is StoredToolResult, which keeps the data_preview the wire excludes because cross_turn_notes reads it back on the next turn. Round and reasoning events are never stored. Every reader - the transcript API, cross_turn_notes, analytics_rollup and the guest API - parses the row through parse_stored_trace, which raises StoredTraceInvalid naming only the row id, never the row; the Django admin's read-only field is the one raw viewer, for a superuser. A row the model rejects is a bug to hear about, not a row to tolerate: the signed-in and guest transcripts answer the generic 500 with an error-level log line naming the row, the next turn of that conversation fails before the agent runs, and the nightly rollup aborts that day and names the row in its failure notice. The transcript returns TranscriptTrace, the same fields over the plain SSE classes plus the turn's rounds, worked out on read by summarize_round, so data_preview stays off the API exactly as it stays off the stream. Migration 0145 parses every stored row and refuses the deploy while one does not fit; python manage.py check_agent_traces runs the same audit on demand and prints the repair. Fields added to an event after rows were stored default to empty: an older trace has no label, kind, service, reason, outcome, held, found, names or duration_ms, and an older confirmation event has no connector_name or proposal_reason. The browser shows such a row by its raw tool name, under a round named "Thought". A turn stored before notes moved out of Message.text keeps its text, and one stored before Message.worked_ms existed reads its step count alone on the fold. TranscriptTrace completes an older confirmation event on read: connector_name becomes the installed connector's display name, or the slug when the connector is gone, and a machine-line summary becomes the action's declared label.

A guest's transcript returns GuestTrace instead, built by guest_trace through the same guest_view the live stream uses (see public-access.md).

The error path

LLM failures raise at their raise sites; nothing in the LLM layer converts a failure into a normal-looking end-of-stream event. A provider exception propagates out of LLMWithTools.generate_with_tools on both endpoint paths, and the Responses adapter (services/agent/responses_adapter.py) raises LLMStreamError for the failures the API delivers as data: a response.failed event, or a stream that ends having produced no content. The one deliberate exception is partial output: response.incomplete and a stream that dies after producing content both complete with what streamed, because a partial answer beats an error message. The backstop is in the loop: a turn that finishes with neither text nor tool calls raises in AgentCore._handle_finish_turn - the single enforcement point of the no-silent-empty-completion invariant, whatever produced the empty turn.

The raise lands in AgentCore.run's outer handler (_emit_run_error), which classifies it with describe_error in services/error_handling/sanitize.py, logging the technical detail and nothing else. It emits an ErrorEvent carrying one of two user-safe sentences, followed by an ErroredDoneEvent carrying the error's kind and the run's trace_id. user_safe_message in the same module is the one table from kind to sentence, so the audit row derives the sentence the person saw from the kind alone. ChatService.generate_response's own boundary emits the same two frames for failures outside the agent (the turn timeout, a permission error), but only when the agent never sent its own done - a trailing error pair after a delivered answer would repaint it as failed. The SPA (web/src/lib/streaming/liveTurns.ts) tells the two endings apart by the phase it writes. A done that reports no error moves the turn to its completed phase, and the chat route's onDone handler then refetches the transcript and discards the record. A done with status="error" moves it to failed, which onDone never sees, so the turn record and the red alert stay on screen exactly as a stopped turn's do. When the failed question was saved before the failure, the banner offers "Try again"; the server announces the saved row's id on every turn, re-runs included, so a failed retry keeps the offer. The button re-runs that saved message as a retry, so the transcript never gains a duplicate question. prepare_edited_turn refuses a retry whose question already has a finished answer, under a row lock in the same transaction as the delete, so a retry never deletes an answer the person could not see; the stream then carries one rerun_refused frame and ends. It hides while a turn is in flight and when there is nothing saved to replay - a failure before the question was persisted leaves retyping as the only recovery.

Source citations ([S#])

The Sources strip under an answer lists only the sources the answer actually cited. The contract lives in one module - services/agent/citations.py - and is enforced at one boundary; the chain is:

  1. Declare - every tool class sets cites_sources: bool, a required BaseTool field (a class that does not declare cannot be instantiated, so the registry fails to build). A citing tool (search, find_entity, read_file) returns its citable content as typed Citation objects on ToolResult.citations; the SourceIdentity union (file / web / uncitable) makes the source identity - or the explicit opt-out - required by construction. citations.source_key is the single home of the file-before-web decision: a FileIdentity that also carries a url is still a file.
  2. Stamp + render - the loop's observation boundary (services/agent/observation_rendering.py) is the single attribution point. It stamps each citation's turn-local ref (AgentState.add_citations, key file:<id> / web:<url>, stable for the whole turn), renders the blocks through citations.render_citations (the only Ref:-rendering code path: Ref:/Title:/Content: plus the tool's extras lines), composes them with the tool's own format_for_llm text, and caps the composite at the per-result budget (ToolResultBudgetMiddleware.cap_observation, half the model's window, clamped to 1,500 to 32,000 tokens), with no fixed cut below that. A result under the cap reaches the model whole; a list over it is cut to the longest prefix of whole rows that fits, under a marker that says how many rows are shown of how many, and anything else over it is cut head and tail with one marker. A tool that returns citations while declaring cites_sources=False aborts the turn with an error - a developer mistake, surfaced loudly.
  3. Cite - the system prompt's citation rule (prompts/agent_system_prompt.py, _SOURCE_CITATION_RULE) tells the model to tag retrieved facts with the ref and never to invent one.
  4. Filter - after the answer, extract_cited_refs(answer) + build_sources(state, cited_refs) (services/agent/source_attribution.py) keep only the cited citations from state.citations and resolve them to Source rows; a SourcesEvent is emitted only when the result is non-empty. A cited ref that resolves to nothing emits the agent.sources.unresolved_citation metric and is dropped - never back-filled.
  5. Persist/strip - chat/chat_service.py strips the [S#] markers from every persisted write (strip_citation_markers) and stores the sources as PersistedTurnTrace.sources (services/chat/turn_trace.py), which the transcript returns as TranscriptTrace.sources for history rendering and the panel. The live SSE text keeps the markers; the SPA strips them at render (web/src/lib/render/citations.ts, the lockstep mirror of services/agent/citations.py).

What each tool cites: search attaches one citation per hit; find_entity attaches one citation per backing source file on its full-payload modes and on every compact browse row (a candidate row in a disambiguation list is navigation, not a citable fact); read_file attaches one citation carrying the returned text. A compact browse row also carries the type's own date (CompactRow.date, from EntityRow.natural_date), so a listing answer such as "what happened in July" is stated and cited from the browse rows themselves rather than from one fetch per row, and a since/until bound applies to that date only - an entity with no date of its own is excluded from a bounded browse, and the browse header says so. A compact row's citation carries no content (the row beside it carries the summary, clipped to 300 characters), a file several rows share is cited once, and every find_entity citation block names its file on a File: line so a row's source_file_ids join it to its ref; that is what lets a full page of rows - top_k is capped at 25 on both browse tools - fit inside the per-result observation budget, and the header names the offset for the next page when the query filled the page. A since/until bound is refused on a type with no date of its own (people, programs, departments, ...) rather than ignored.

A search hit is a typed model, not a dict. services/agent/search_hit.py is the shape's single home: CampusHit for scope="campus", ContextHit for scope="my_context", both built where the SearchResults are mined. A hit has exactly two projections, each with one writer - to_payload() produces the plain dict that rides on ToolResult.data into span storage and the admin trace viewer, and to_citation() produces the Citation the observation boundary stamps and renders. Renaming a citation-bearing field therefore fails ty check instead of quietly emptying the Sources panel. A citing tool renders nothing beside its citations by default: renders_as_citations_only is the single home of that rule, read by the base format_for_llm to decide it contributes no text and by the boundary to decide it appends the blocks, so a hit reaches the model only as the boundary's block and no search tool has a rendering table to keep in step with _extra_lines. find_entity is the one override, rendering its payload beside its blocks because the agent follows the payload's source_file_ids into read_file.

Of the three SourceIdentity arms, only FileIdentity has a production writer today: every search hit comes from a chunk with a parent file, and find_entity and read_file cite files too. WebIdentity and Uncitable stay in the union as the contract a future web-only or deliberately non-citable tool declares against; build_sources already handles both.

A new citing tool needs zero wiring beyond the declaration and its Citation list - that contract is pinned by tests/test_source_attribution.py (TestObservationBoundary, which drives a never-registered fake tool through the loop's observation boundary and an unloaded tool through the base default; TestSearchObservationByteStability pins the exact block bytes; registry construction proves every class declares). The default's citation rule is pinned on both sides in tests/test_agent_tools.py (TestBaseToolFormatForLlmDefault) and driven end-to-end through the two composed-only search tools in tests/test_agent_search_tool.py (TestComposedToolsRenderOnlyThroughCitations); find_entity's override is pinned by test_observation_renders_payload_beside_blocks. The persistence wiring is pinned by TestCitedTurnPersistsSources.