qwestly-workspace/docs/agent-conversations-schema-redesign.md
Table of Contents
Agent Conversations Schema Redesign
Problem
The current agent_conversations collection stores turns (not messages). Each document has user_message + response_parts + tool_calls + response_text. This has several issues:
- Not standard — OpenAI/Anthropic use
{role, content}per message. Our "turn" model conflates user input, assistant output, tool calls, tool results, and file uploads into a single document. - System messages are invisible —
is_system: truestoresuser_message: "", losing the system trigger content. System-initialized turns produce no visible message in the transcript. - Tool results are misordered — SSE streaming emits tool_results after the agent run completes, so they're at the end of
response_parts. Reordering happens at read time (fixed in prior PR), but this is a symptom of the turn model being the wrong abstraction. - HITL is separate —
session_hitlcollection stores pending file-upload prompts separately from message history. Undiscoverable, hard to reason about. - No unique tool_call_ids — tool calls and results are correlated by
tool_namealone. Same-tool-name calls in one turn overwrite each other.
Target Schema
New: agent_conversations (one doc per conversation)
Replaces the current agent_conversations collection. Stores high-level metadata only.
interface IAgentConversation {
conversation_id: string; // UUID
user_id: string;
name: string | null; // display name (auto-generated from first message or LLM)
created_at: Date;
updated_at: Date; // last message time
turn_count: number; // denormalized count for sidebar
last_message_preview: string; // truncated preview of last user/assistant message
}
New: agent_messages (one doc per message)
Each message follows the OpenAI format (plus extensions for HITL, error, upload metadata).
interface IAgentMessage {
conversation_id: string; // UUID — groups messages into a conversation
role: "system" | "user" | "assistant" | "tool";
content: string | null;
// Present on assistant messages that include tool calls:
tool_calls?: {
id: string; // unique tool_call_id (from Pydantic AI)
type: "function";
function: {
name: string;
arguments: string; // JSON string of args
};
}[] | null;
// Present on tool messages (links back to the assistant tool_call):
tool_call_id?: string | null;
// Present on assistant messages that trigger a HITL widget
// (file_upload, future: confirmation, form):
hitl_action?: {
action: string;
params: Record<string, any>;
request_id: string;
} | null;
// Present on any message that represents an error:
error?: { message: string } | null;
// Present on user messages that carry a file upload:
upload?: {
filename: string;
file_type: string;
size: number;
parsed_text?: string;
} | null;
// Timing + cost metadata:
created_at: Date;
token_usage?: { input_tokens: number; output_tokens: number } | null;
latency_ms?: number | null;
}
Deprecate: session_hitl
HITL state moves into agent_messages.hitl_action. Pending HITL is determined by querying for the most recent message with a hitl_action that hasn't been resolved yet. Resolved state can be tracked via a hitl_resolved_at field in the conversation doc or handled by the client.
Message Mapping
| Event / Source | Role | Fields |
|---|---|---|
System trigger (is_system: true) |
system |
content: request.message |
| User text input | user |
content: "..." |
| User file upload | user |
content: "I've uploaded...", upload: {...} |
| Assistant text response | assistant |
content: "Here's your grade..." |
| Assistant tool call | assistant |
content: null, tool_calls: [{id, function: {name, arguments}}] |
| Tool result | tool |
tool_call_id: "...", content: "{...json...}" |
| HITL action (file upload prompt) | assistant |
content: "Upload your resume...", hitl_action: {action: "file_upload", ...} |
| Error | assistant |
content: null, error: {message: "..."} |
Example conversation as stored messages
msg 1: { role: "system", content: "" }
msg 2: { role: "assistant", content: "Hi there! Welcome...", token_usage: {...} }
msg 3: { role: "user", content: "dominickpham" }
msg 4: { role: "assistant", content: null, tool_calls: [{ id: "tc1", function: { name: "ingest_linkedin_profile", arguments: "..." } }] }
msg 5: { role: "tool", tool_call_id: "tc1", content: "{ ...linkedin data... }" }
msg 6: { role: "assistant", content: "Got your profile. Let me grade it..." }
msg 7: { role: "assistant", content: null, tool_calls: [{ id: "tc2", function: { name: "grade_linkedin_profile", arguments: "{}" } }] }
msg 8: { role: "tool", tool_call_id: "tc2", content: "{ ...grade data... }" }
msg 9: { role: "assistant", content: "Here's your grade: 5.7/10..." }
msg 10: { role: "assistant", content: "Upload your resume...", hitl_action: { action: "file_upload", request_id: "h1", params: {...} } }
msg 11: { role: "user", content: "I've uploaded Career notes.pdf", upload: { filename: "Career notes.pdf", file_type: "pdf", size: 59690, parsed_text: "..." } }
msg 12: { role: "assistant", content: "Got your Career notes. Gap check: ..." }
Implementation Plan
Phase 1: Types & Schema (no data migration)
-
Create TypeScript types in
candidate/src/types/agent-message.d.ts:IAgentMessageIAgentConversationIToolCall,IHitlAction,IFileUpload
-
Update Python schemas in
qwestly-agent/lib/schemas.py:- Add
tool_call_idtoToolCallEventandToolResultEvent - Update
ChatRequest(already hasupload_result,is_system)
- Add
-
Update SSE streaming in
qwestly-agent/agents/base.py:- Read
part.tool_call_idfrom Pydantic AI'sToolCallPartand pass it through SSE events - Read
part.tool_call_idfromToolReturnPartand pass it through SSE result events - Update
_collect_tool_resultsto track bytool_call_idinstead of deduplicating bytool_name
- Read
-
Update SSE ingest in
qwestly-agent/lib/sse_ingest.py:- Store
tool_call_idin both tool_call and tool_result entries
- Store
Phase 2: New logging layer
-
Create
AgentMessageLoggerinqwestly-agent/lib/logging.py:async def log_message(msg: AgentMessage)— inserts one doc intoagent_messagesasync def log_messages(messages: list[AgentMessage])— bulk insert a turn's messagesasync def upsert_conversation(conv_id, user_id, ...)— upserts conversation metadata
-
Update chat endpoint (
qwestly-agent/api/index.py):- After streaming completes, convert
response_parts+user_messageinto a list ofAgentMessagedocs - Write messages individually to
agent_messages - Upsert conversation metadata to
agent_conversations - Remove old
log_turn()call
- After streaming completes, convert
-
Update test routes (
qwestly-agent/api/test_routes.py):- Same logging changes
Phase 3: Read paths
-
Update conversation list (
list_sessionsin logging.py):- Query
agent_conversationsfor metadata (simple find + sort) - Remove aggregation pipeline against turns
- Query
-
Update transcript endpoint (
get_conversation_transcript):- Query
agent_messages.find({conversation_id}).sort({created_at: 1}) - Messages are already in correct order (no reordering needed!)
- Remove
_interleave_tool_resultsand backfill logic — no longer needed - Map
agent_messagesdocs to the transcript response format
- Query
-
Update get_session — query
agent_messagesinstead of turns -
Update delete_session — delete from both
agent_messagesandagent_conversations -
Update name_session — upsert into
agent_conversations -
Update count_turns — read
turn_countfromagent_conversations(denormalized) or countagent_messageswhere role isuserorsystem -
Update candidate admin route (
src/app/api/admin/agent/conversations/[id]/route.ts):
- Query
agent_messagesdirectly (simple find + sort) - Remove
interleaveToolResults— no longer needed - Map to the response format
Phase 4: HITL consolidation
- Remove
session_hitlcollection:
- HITL pending state is determined by finding the last
hitl_actionmessage without a corresponding resolved marker - Add
hitl_resolved_atfield toagent_conversationsor track via a specialtoolmessage
- Update HITL API routes to read/write from
agent_messages:
get_pending_hitl→ find last message withhitl_actionwhere not yet resolvedset_pending_hitl→ already handled by logging the assistant message withhitl_actionclear_pending_hitl→ insert a resolution marker or update conversation doc
Phase 5: Data migration
- Write migration script (Python, run manually in dev):
For each conversation_id group in old agent_conversations:
1. Create agent_conversations doc with metadata
2. For each turn (sorted by created_at):
a. If user_message is non-empty → insert {role: "user", content}
If user_message is empty → insert {role: "system", content: ""}
b. For each part in response_parts:
- text → {role: "assistant", content}
- tool_call → {role: "assistant", content: null, tool_calls: [{id: new_uuid, function}]}
- tool_result → {role: "tool", tool_call_id: matched_call_id, content: serialized_result}
- file_upload → {role: "user", content: "File uploaded", upload: {...}}
- Verify migration — spot-check conversations render identically in the admin UI
Phase 6: Cleanup & docs
-
Update candidate types — align
ChatMessageinterface to include new roles/fields -
Update admin transcript renderer — handle
systemrole, updated tool message format -
Update live chat client — minor adjustments for
tool_call_idin SSE events -
Remove deprecated code — old
log_turn,update_turn,_interleave_tool_results, backfill logic -
Delete claim-migration references — update user_id migration in
capture_email.pyto targetagent_messages
Migration Compatibility
- New code writes to both old and new collections during a transition period (controlled by env flag)
- Old
agent_conversationsrenamed toagent_conversations_deprecatedafter migration verified - Admin route reads from new collection first, falls back to old for unmigrated data
Open Questions
-
HITL resolved state — How do we mark a HITL action as resolved? Options:
- (A)
hitl_resolved_atfield on the conversation doc - (B) A special
{role: "tool", content: "hitl_resolved", tool_call_id: request_id}message - (C) Rely on the next user message with
uploadmetadata being the resolution → Leaning (C) — the file upload user message IS the resolution. No extra state needed.
- (A)
-
token_usage placement — Should token_usage be on every message, only on assistant messages, or only on the conversation doc? → On assistant messages only. System/user/tool messages don't consume tokens directly.
-
Auto-name triggers — Currently name_session updates ALL turns. With per-message storage, name only exists on the conversation doc. Simpler.
-
message_history_json — Currently stored per-turn for Pydantic AI cross-turn context. With per-message storage, we can reconstruct this from
agent_messages. But Pydantic AI needsModelMessageobjects, not plain dicts. We'll keep using_mh_cache(in-memory) and the last document'smessage_history_jsonduring migration, then switch to reconstructing fromagent_messages. -
capture_email.pymigration — Currently runsupdate_many({user_id: from}, {$set: {user_id: to}})onagent_conversations. Needs to update bothagent_conversationsandagent_messages. -
provisioned-account.tscascade delete — Currently runsdeleteMany({user_id})onagent_conversations. Needs to run on both collections.
Files Changed (estimated)
qwestly-agent
| File | Change |
|---|---|
lib/logging.py |
Major: add AgentMessageLogger, modify ConversationLogger |
lib/schemas.py |
Add tool_call_id fields, new AgentMessage model |
lib/sse_ingest.py |
Store tool_call_id |
agents/base.py |
Pass tool_call_id from Pydantic AI parts |
api/index.py |
New logging, new transcript query, remove reorder/backfill |
api/test_routes.py |
Same changes as index.py |
tools/capture_email.py |
Update migration queries |
docs/message-format.md |
Rewrite for new schema |
tests/test_logging.py |
Update for new logger API |
candidate
| File | Change |
|---|---|
src/types/agent-message.d.ts |
New file — IAgentMessage, IAgentConversation types |
src/types/chatbot_messages.d.ts |
Update ChatMessage to add system role |
src/app/api/admin/agent/conversations/[id]/route.ts |
Query agent_messages, remove interleaveToolResults |
src/app/agent/client.tsx |
Handle tool_call_id in SSE, minor type updates |
src/app/agent/_components/message-bubble.tsx |
Handle system role rendering |
src/app/admin/provision/user-initiated/client.tsx |
Update transcript renderer for new format |
src/services/db/provisioned-account.ts |
Delete from both collections |
src/services/db/claim-migration.ts |
Migrate both collections |