- Added `locale` query parameter for assistant information retrieval, allowing for localization based on user preferences. - Introduced `assistants` field in the response to provide localized details (name, avatar, description) for unique assistant IDs, improving frontend display capabilities. - Updated documentation to clarify locale resolution priority and the significance of the new `assistants` field in chat responses.
1752 lines
63 KiB
Markdown
1752 lines
63 KiB
Markdown
# Chat Storage Design
|
|
|
|
This document describes the design for storing chat conversations, messages, and execution steps in the YAO Agent system.
|
|
|
|
## Table of Contents
|
|
|
|
- [Overview](#overview)
|
|
- [Architecture](#architecture)
|
|
- [Data Models](#data-models)
|
|
- [Write Strategy](#write-strategy)
|
|
- [API Interface](#api-interface)
|
|
- [Usage Examples](#usage-examples)
|
|
- [Related Documents](#related-documents)
|
|
|
|
## Overview
|
|
|
|
The chat storage system is designed to:
|
|
|
|
1. **Store user-visible messages** - All messages sent via `ctx.Send()`, including text, images, loading states, etc.
|
|
2. **Support resume/retry** - Track execution steps to enable recovery from interruptions or failures
|
|
3. **Efficient writes** - Batch message writes at request end
|
|
|
|
### Design Goals
|
|
|
|
| Goal | Solution |
|
|
| ------------------------ | ------------------------------------------------ |
|
|
| Complete chat history | Store final content of all `ctx.Send()` messages |
|
|
| Resume from interruption | Track step status and input/output |
|
|
| Retry failed operations | Store step input for re-execution |
|
|
| Minimize database writes | Batch writes at request end |
|
|
|
|
### Non-Goals
|
|
|
|
- **Tracing/debugging** - Handled by separate [Trace module](../../trace/README.md)
|
|
- **Streaming replay** - Not needed, history shows final content only
|
|
- **Request tracking/billing** - Handled by [OpenAPI Request module](../../openapi/request/REQUEST_DESIGN.md)
|
|
|
|
### Relationship with OpenAPI Request
|
|
|
|
The Agent storage focuses on **chat content and execution state**, while request tracking (billing, rate limiting, auditing) is handled globally by the OpenAPI layer:
|
|
|
|
| Concern | Module | Table |
|
|
| ---------------- | ----------------- | ----------------- |
|
|
| Request tracking | `openapi/request` | `openapi_request` |
|
|
| Billing (tokens) | `openapi/request` | `openapi_request` |
|
|
| Rate limiting | `openapi/request` | - |
|
|
| Chat sessions | `agent/store` | `agent_chat` |
|
|
| Chat messages | `agent/store` | `agent_message` |
|
|
| Resume/Retry | `agent/store` | `agent_resume` |
|
|
|
|
The `request_id` from OpenAPI middleware is passed to Agent and stored in messages/steps for correlation.
|
|
|
|
## Architecture
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ Chat Storage │
|
|
├─────────────────────────────────────────────────────────────┤
|
|
│ │
|
|
│ ┌─────────────────┐ │
|
|
│ │ Chat │ Metadata: title, assistant, user │
|
|
│ └────────┬────────┘ │
|
|
│ │ │
|
|
│ │ 1:N │
|
|
│ ▼ │
|
|
│ ┌─────────────────┐ │
|
|
│ │ Message │ User-visible: type, props, role │
|
|
│ └────────┬────────┘ │
|
|
│ │ │
|
|
│ │ N:N (via request_id) │
|
|
│ ▼ │
|
|
│ ┌─────────────────┐ │
|
|
│ │ Resume │ Recovery: type, status, input/output │
|
|
│ │ (only on fail) │ Only saved when interrupted/failed │
|
|
│ └─────────────────┘ │
|
|
│ │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
## Data Models
|
|
|
|
### 1. Chat Table
|
|
|
|
Stores chat metadata and session information.
|
|
|
|
**Table Name:** `agent_chat`
|
|
|
|
| Column | Type | Nullable | Index | Description |
|
|
| ----------------- | ----------- | -------- | ------ | -------------------------------- |
|
|
| `id` | ID | No | PK | Auto-increment primary key |
|
|
| `chat_id` | string(64) | No | Unique | Unique chat identifier |
|
|
| `title` | string(500) | Yes | - | Chat title |
|
|
| `assistant_id` | string(200) | No | Yes | Associated assistant ID |
|
|
| `last_connector` | string(200) | Yes | Yes | Last used connector ID |
|
|
| `last_mode` | string(50) | Yes | - | Last used chat mode (chat/task) |
|
|
| `status` | enum | No | Yes | Status: `active`, `archived` |
|
|
| `public` | boolean | No | - | Whether shared across all teams |
|
|
| `share` | enum | No | Yes | Sharing scope: `private`, `team` |
|
|
| `sort` | integer | No | - | Sort order for display |
|
|
| `last_message_at` | timestamp | Yes | Yes | Timestamp of last message |
|
|
| `metadata` | json | Yes | - | Additional metadata |
|
|
| `created_at` | timestamp | No | Yes | Creation timestamp |
|
|
| `updated_at` | timestamp | No | - | Last update timestamp |
|
|
|
|
**Model Options:**
|
|
|
|
```json
|
|
{
|
|
"option": {
|
|
"soft_deletes": true,
|
|
"permission": true,
|
|
"timestamps": true
|
|
}
|
|
}
|
|
```
|
|
|
|
**Note:** `permission: true` enables Yao's built-in permission management, which automatically adds the following fields:
|
|
|
|
| Field | Type | Description |
|
|
| ------------------ | ----------- | ------------------------------ |
|
|
| `__yao_created_by` | string(200) | User ID who created the record |
|
|
| `__yao_updated_by` | string(200) | User ID who last updated |
|
|
| `__yao_team_id` | string(200) | Team ID for team-level access |
|
|
| `__yao_tenant_id` | string(200) | Tenant ID for multi-tenancy |
|
|
|
|
These fields are automatically managed by the framework and used for access control filtering.
|
|
|
|
**Indexes:**
|
|
|
|
| Name | Columns | Type |
|
|
| -------------------- | ----------------- | ----- |
|
|
| `idx_chat_assistant` | `assistant_id` | index |
|
|
| `idx_chat_last_conn` | `last_connector` | index |
|
|
| `idx_chat_status` | `status` | index |
|
|
| `idx_chat_share` | `share` | index |
|
|
| `idx_chat_last_msg` | `last_message_at` | index |
|
|
|
|
### 2. Message Table
|
|
|
|
Stores user-visible messages (both user input and assistant responses).
|
|
|
|
**Table Name:** `agent_message`
|
|
|
|
| Column | Type | Nullable | Index | Description |
|
|
| -------------- | ----------- | -------- | ----- | ------------------------------------------- |
|
|
| `id` | ID | No | PK | Auto-increment primary key |
|
|
| `message_id` | string(64) | No | - | Message identifier (unique within request) |
|
|
| `chat_id` | string(64) | No | Yes | Parent chat ID |
|
|
| `request_id` | string(64) | Yes | Yes | Request ID for grouping |
|
|
| `role` | enum | No | Yes | Role: `user`, `assistant` |
|
|
| `type` | string(50) | No | - | Message type (text, image, loading, etc.) |
|
|
| `props` | json | No | - | Message properties (content, url, etc.) |
|
|
| `block_id` | string(64) | Yes | Yes | Block grouping ID |
|
|
| `thread_id` | string(64) | Yes | Yes | Thread grouping ID |
|
|
| `assistant_id` | string(200) | Yes | Yes | Assistant ID (join to get name/avatar) |
|
|
| `connector` | string(200) | Yes | Yes | Connector ID used for this message |
|
|
| `mode` | string(50) | Yes | - | Chat mode used for this message (chat/task) |
|
|
| `sequence` | integer | No | - | Message order within chat (in composite) |
|
|
| `metadata` | json | Yes | - | Additional metadata |
|
|
| `created_at` | timestamp | No | Yes | Creation timestamp |
|
|
| `updated_at` | timestamp | No | - | Last update timestamp |
|
|
|
|
**Indexes:**
|
|
|
|
| Name | Columns | Type |
|
|
| ------------------------- | -------------------------- | ------ |
|
|
| `idx_msg_chat_seq` | `chat_id`, `sequence` | index |
|
|
| `idx_msg_request_message` | `request_id`, `message_id` | unique |
|
|
| `idx_msg_request` | `request_id` | index |
|
|
| `idx_msg_role` | `role` | index |
|
|
| `idx_msg_block` | `block_id` | index |
|
|
| `idx_msg_thread` | `thread_id` | index |
|
|
| `idx_msg_assistant` | `assistant_id` | index |
|
|
|
|
**Message Ordering:**
|
|
|
|
Messages are ordered by `created_at` first, then by `sequence` within the same timestamp. This ensures correct chronological order when there are multiple requests with overlapping sequence numbers:
|
|
|
|
```sql
|
|
ORDER BY created_at ASC, sequence ASC
|
|
```
|
|
|
|
**Why this ordering?**
|
|
|
|
- `sequence` is assigned per-request, so different requests may have the same sequence numbers
|
|
- `created_at` groups messages by request time, ensuring messages from earlier requests appear first
|
|
- Within the same request (same `created_at`), `sequence` preserves the internal ordering
|
|
|
|
**Message Types:**
|
|
|
|
All message types are stored, including built-in types and custom types. See `agent/output/BUILTIN_TYPES.md` for built-in Props structures.
|
|
|
|
| Type | Description | Props Example | Stored? |
|
|
| ------------ | -------------------------------- | ------------------------------------------------------------------------------------------- | ------- |
|
|
| `user_input` | User input (frontend display) | `{"content": "Hello", "role": "user", "name": "John"}` | ✅ Yes |
|
|
| `text` | Text/Markdown content | `{"content": "Hello **world**!"}` | ✅ Yes |
|
|
| `thinking` | Reasoning process (o1, DeepSeek) | `{"content": "Let me analyze..."}` | ✅ Yes |
|
|
| `loading` | Loading/processing indicator | `{"message": "Searching knowledge base..."}` | ✅ Yes |
|
|
| `tool_call` | LLM tool/function call | `{"id": "call_abc123", "name": "get_weather", "arguments": "{\"location\":\"SF\"}"}` | ✅ Yes |
|
|
| `retrieval` | KB/Web search results | `{"query": "...", "sources": [...], "total_results": 10}` | ✅ Yes |
|
|
| `error` | Error message | `{"message": "Connection timeout", "code": "TIMEOUT", "details": "..."}` | ✅ Yes |
|
|
| `image` | Image content | `{"url": "...", "alt": "...", "width": 200, "height": 200, "detail": "auto"}` | ✅ Yes |
|
|
| `audio` | Audio content | `{"url": "...", "format": "mp3", "duration": 120.5, "transcript": "...", "controls": true}` | ✅ Yes |
|
|
| `video` | Video content | `{"url": "...", "format": "mp4", "thumbnail": "...", "width": 640, "height": 360}` | ✅ Yes |
|
|
| `action` | System action (CUI only) | `{"name": "open_panel", "payload": {"panel_id": "user_profile"}}` | ✅ Yes |
|
|
| `event` | Lifecycle event (CUI only) | `{"event": "stream_start", "message": "...", "data": {...}}` | ❌ No |
|
|
| `*` (custom) | Any custom type | `{"chartType": "bar", "data": [...], "options": {...}}` | ✅ Yes |
|
|
|
|
**Note on `event` type:** Lifecycle events (`stream_start`, `stream_end`, etc.) are transient control signals and are NOT stored. They are only used for real-time streaming coordination.
|
|
|
|
**Note on custom types:** Any type not in the built-in list is stored as-is with its original `type` and `props` structure.
|
|
|
|
**Tool Call Storage:**
|
|
|
|
Tool calls from LLM responses are stored as `tool_call` type messages. The raw tool call data is preserved in `props`:
|
|
|
|
```json
|
|
{
|
|
"message_id": "msg_001",
|
|
"chat_id": "chat_123",
|
|
"role": "assistant",
|
|
"type": "tool_call",
|
|
"props": {
|
|
"id": "call_abc123",
|
|
"name": "get_weather",
|
|
"arguments": "{\"location\": \"San Francisco\", \"unit\": \"celsius\"}"
|
|
},
|
|
"block_id": "B1",
|
|
"sequence": 5
|
|
}
|
|
```
|
|
|
|
**Tool Result Storage:**
|
|
|
|
Tool execution results can be stored as `text` type with metadata indicating it's a tool result:
|
|
|
|
```json
|
|
{
|
|
"message_id": "msg_002",
|
|
"chat_id": "chat_123",
|
|
"role": "assistant",
|
|
"type": "text",
|
|
"props": {
|
|
"content": "The weather in San Francisco is 18°C and sunny."
|
|
},
|
|
"metadata": {
|
|
"tool_call_id": "call_abc123",
|
|
"tool_name": "get_weather",
|
|
"is_tool_result": true
|
|
},
|
|
"block_id": "B1",
|
|
"sequence": 6
|
|
}
|
|
```
|
|
|
|
**Custom Types:**
|
|
|
|
Any type not in the built-in list is considered a custom type and stored with its original structure:
|
|
|
|
```json
|
|
{
|
|
"type": "chart",
|
|
"props": {
|
|
"chartType": "bar",
|
|
"data": [...],
|
|
"options": {...}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Multimodal User Input:**
|
|
|
|
User input with multimodal content (text + images + files) is stored as `user_input` type:
|
|
|
|
```json
|
|
{
|
|
"message_id": "msg_000",
|
|
"chat_id": "chat_123",
|
|
"role": "user",
|
|
"type": "user_input",
|
|
"props": {
|
|
"content": [
|
|
{ "type": "text", "text": "What's in this image?" },
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {
|
|
"url": "https://example.com/photo.jpg",
|
|
"detail": "high"
|
|
}
|
|
}
|
|
],
|
|
"role": "user",
|
|
"name": "John"
|
|
},
|
|
"sequence": 1
|
|
}
|
|
```
|
|
|
|
### Knowledge Base & Web Search Results
|
|
|
|
Retrieval results from knowledge bases and web searches need to be stored for:
|
|
|
|
1. **User Feedback** - Users can rate (👍/👎) individual sources
|
|
2. **Quality Analytics** - Track which documents/sources are most useful
|
|
3. **Source Attribution** - Display citations in the UI
|
|
4. **RAG Optimization** - Improve retrieval based on feedback
|
|
|
|
**Storage Approach:** Store retrieval results as a special message type `retrieval` with structured props.
|
|
|
|
**Retrieval Message Structure:**
|
|
|
|
```json
|
|
{
|
|
"message_id": "msg_retrieval_001",
|
|
"chat_id": "chat_123",
|
|
"request_id": "req_abc",
|
|
"role": "assistant",
|
|
"type": "retrieval",
|
|
"props": {
|
|
"query": "How to configure Yao models?",
|
|
"sources": [
|
|
{
|
|
"id": "src_001",
|
|
"type": "kb",
|
|
"collection_id": "col_docs",
|
|
"document_id": "doc_123",
|
|
"chunk_id": "chunk_456",
|
|
"title": "Model Configuration Guide",
|
|
"content": "To configure a model in Yao, create a .mod.yao file...",
|
|
"score": 0.92,
|
|
"metadata": {
|
|
"file_path": "/docs/model.md",
|
|
"page": 3
|
|
}
|
|
},
|
|
{
|
|
"id": "src_002",
|
|
"type": "kb",
|
|
"collection_id": "col_docs",
|
|
"document_id": "doc_124",
|
|
"chunk_id": "chunk_789",
|
|
"title": "Advanced Model Options",
|
|
"content": "Models support various options including soft_deletes...",
|
|
"score": 0.87,
|
|
"metadata": {
|
|
"file_path": "/docs/advanced.md",
|
|
"page": 12
|
|
}
|
|
},
|
|
{
|
|
"id": "src_003",
|
|
"type": "web",
|
|
"url": "https://yaoapps.com/docs/models",
|
|
"title": "Yao Models Documentation",
|
|
"content": "Official documentation for Yao model system...",
|
|
"score": 0.85,
|
|
"metadata": {
|
|
"domain": "yaoapps.com",
|
|
"fetched_at": "2024-01-15T10:30:00Z"
|
|
}
|
|
}
|
|
],
|
|
"total_results": 15,
|
|
"query_time_ms": 120
|
|
},
|
|
"block_id": "B1",
|
|
"assistant_id": "docs_assistant",
|
|
"sequence": 2
|
|
}
|
|
```
|
|
|
|
**Source Types:**
|
|
|
|
| Type | Description | Key Fields |
|
|
| ------ | ----------------------- | ------------------------------------------ |
|
|
| `kb` | Knowledge base document | `collection_id`, `document_id`, `chunk_id` |
|
|
| `web` | Web search result | `url`, `domain` |
|
|
| `file` | Uploaded file | `file_id`, `file_path` |
|
|
| `api` | External API result | `api_name`, `endpoint` |
|
|
| `mcp` | MCP tool result | `server`, `tool` |
|
|
|
|
**Source Feedback:**
|
|
|
|
User feedback on retrieval sources is handled by the Knowledge Base module. See [KB Feedback](../../kb/README.md) for details.
|
|
|
|
**Example: KB Search in Create Hook:**
|
|
|
|
```typescript
|
|
// In Create hook, search knowledge base and store results
|
|
const results = await ctx.kb.search("col_docs", query, { limit: 5 });
|
|
|
|
// Send retrieval message (stored automatically)
|
|
ctx.Send({
|
|
type: "retrieval",
|
|
props: {
|
|
query: query,
|
|
sources: results.documents.map((doc, idx) => ({
|
|
id: `src_${idx}`,
|
|
type: "kb",
|
|
collection_id: "col_docs",
|
|
document_id: doc.document.metadata.document_id,
|
|
chunk_id: doc.document.id,
|
|
title: doc.document.metadata.title || "Untitled",
|
|
content: doc.document.content,
|
|
score: doc.score,
|
|
metadata: doc.document.metadata,
|
|
})),
|
|
total_results: results.total,
|
|
query_time_ms: results.query_time_ms,
|
|
},
|
|
});
|
|
|
|
// Also send loading message for user feedback
|
|
ctx.Send({
|
|
type: "loading",
|
|
props: { message: `Found ${results.total} relevant documents...` },
|
|
});
|
|
```
|
|
|
|
**Example: Web Search Results:**
|
|
|
|
```json
|
|
{
|
|
"type": "retrieval",
|
|
"props": {
|
|
"query": "latest AI news 2024",
|
|
"sources": [
|
|
{
|
|
"id": "src_001",
|
|
"type": "web",
|
|
"url": "https://example.com/ai-news",
|
|
"title": "AI Breakthroughs in 2024",
|
|
"content": "Summary of the article...",
|
|
"score": 0.95,
|
|
"metadata": {
|
|
"domain": "example.com",
|
|
"published_at": "2024-01-10",
|
|
"fetched_at": "2024-01-15T10:30:00Z",
|
|
"snippet": "The year 2024 has seen remarkable..."
|
|
}
|
|
}
|
|
],
|
|
"provider": "tavily",
|
|
"total_results": 10,
|
|
"query_time_ms": 850
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3. Resume Table
|
|
|
|
Stores execution state for resume/retry functionality. **Only written when request is interrupted or failed.**
|
|
|
|
**Table Name:** `agent_resume`
|
|
|
|
| Column | Type | Nullable | Index | Description |
|
|
| ----------------- | ----------- | -------- | ------ | ---------------------------------------- |
|
|
| `id` | ID | No | PK | Auto-increment primary key |
|
|
| `resume_id` | string(64) | No | Unique | Unique resume record identifier |
|
|
| `chat_id` | string(64) | No | Yes | Parent chat ID |
|
|
| `request_id` | string(64) | No | Yes | Request ID |
|
|
| `assistant_id` | string(200) | No | Yes | Assistant executing this step |
|
|
| `stack_id` | string(64) | No | Yes | Stack node ID for this execution |
|
|
| `stack_parent_id` | string(64) | Yes | Yes | Parent stack ID (for A2A calls) |
|
|
| `stack_depth` | integer | No | - | Call depth (0=root, 1+=nested) |
|
|
| `type` | enum | No | Yes | Step type |
|
|
| `status` | enum | No | Yes | Status: `interrupted`, `failed` |
|
|
| `input` | json | Yes | - | Step input data |
|
|
| `output` | json | Yes | - | Step output data (partial) |
|
|
| `space_snapshot` | json | Yes | - | Space data snapshot for recovery |
|
|
| `error` | text | Yes | - | Error message if failed |
|
|
| `sequence` | integer | No | - | Step order within request (in composite) |
|
|
| `metadata` | json | Yes | - | Additional metadata |
|
|
| `created_at` | timestamp | No | Yes | Creation timestamp |
|
|
| `updated_at` | timestamp | No | - | Last update timestamp |
|
|
|
|
**Space Snapshot:**
|
|
|
|
The `space_snapshot` field stores the shared data space (`ctx.Space`) at each step for recovery purposes.
|
|
|
|
```typescript
|
|
// Example: In Next hook, set data to Space before delegate
|
|
ctx.space.Set("choose_prompt", "query");
|
|
return {
|
|
delegate: { agent_id: "expense", messages: payload.messages },
|
|
};
|
|
```
|
|
|
|
If interrupted during delegate, the `space_snapshot` allows restoring `ctx.Space` state:
|
|
|
|
```json
|
|
{
|
|
"choose_prompt": "query",
|
|
"user_preferences": { "currency": "USD" }
|
|
}
|
|
```
|
|
|
|
**Resume Step Types:**
|
|
|
|
| Type | Description | Input | Output |
|
|
| ------------- | --------------------- | ---------------------- | ------------------------------------- |
|
|
| `input` | User input received | `{messages: [...]}` | - |
|
|
| `hook_create` | Create hook execution | `{messages: [...]}` | `{messages: [...], ...}` |
|
|
| `llm` | LLM completion call | `{messages: [...]}` | `{content: "...", tool_calls: [...]}` |
|
|
| `tool` | Tool/MCP execution | `{server, tool, args}` | `{result: ...}` |
|
|
| `hook_next` | Next hook execution | `{completion, tools}` | `{data: ...}` |
|
|
| `delegate` | A2A delegation | `{agent_id, messages}` | `{response: ...}` |
|
|
|
|
**Resume Status (only two values - table only stores failed/interrupted):**
|
|
|
|
| Status | Description | Action |
|
|
| ------------- | ----------------- | -------- |
|
|
| `failed` | Failed with error | Retry |
|
|
| `interrupted` | User interrupted | Continue |
|
|
|
|
**Indexes:**
|
|
|
|
| Name | Columns | Type |
|
|
| ---------------------- | ------------------------ | ----- |
|
|
| `idx_resume_chat` | `chat_id` | index |
|
|
| `idx_resume_request` | `request_id`, `sequence` | index |
|
|
| `idx_resume_type` | `type` | index |
|
|
| `idx_resume_status` | `status` | index |
|
|
| `idx_resume_stack` | `stack_id` | index |
|
|
| `idx_resume_parent` | `stack_parent_id` | index |
|
|
| `idx_resume_assistant` | `assistant_id` | index |
|
|
|
|
## Write Strategy
|
|
|
|
### Single-Write Strategy
|
|
|
|
All data is buffered in memory during execution and written to database **only once** when `Stream()` exits:
|
|
|
|
**Note**: Request tracking (status, tokens, duration) is handled by [OpenAPI Request Middleware](../../openapi/request/REQUEST_DESIGN.md).
|
|
|
|
```
|
|
Stream() Entry
|
|
│
|
|
├── Buffer user input message (role=user)
|
|
│
|
|
├── Execution (all in memory)
|
|
│ - ctx.Send() → messageBuffer
|
|
│ - ctx.Append() → update messageBuffer
|
|
│ - ctx.Replace() → update messageBuffer
|
|
│ - Each step → stepBuffer
|
|
│
|
|
└── 【Single Write】Save final state (via defer)
|
|
│
|
|
├── Always:
|
|
│ - Batch write all messages (user input + assistant responses)
|
|
│ - Update token usage in openapi_request (via request_id)
|
|
│
|
|
└── Only on error/interrupt:
|
|
- Batch write all steps (for resume/retry)
|
|
```
|
|
|
|
### Write Points
|
|
|
|
| Event | Message Table | Step Table | Token Usage |
|
|
| ---------------- | -------------------------------------- | ----------------------------------- | ----------- |
|
|
| Stream entry | Buffer user input | - | - |
|
|
| During execution | Buffer in memory | Buffer in memory | - |
|
|
| **Completed** | **Batch write all (user + assistant)** | **❌ Skip (no need to resume)** | ✅ Update |
|
|
| On interrupt | Batch write all buffered | ✅ Batch write (status=interrupted) | ✅ Update |
|
|
| On error | Batch write all buffered | ✅ Batch write (status=failed) | ✅ Update |
|
|
|
|
**Why skip Steps on success?**
|
|
|
|
- Steps are only needed for resume/retry operations
|
|
- If completed successfully, there's nothing to resume
|
|
- Reduces database writes and keeps Resume table clean
|
|
|
|
### Why Single Write?
|
|
|
|
| Scenario | What Happens | Data Safe? |
|
|
| ------------------ | --------------------------------- | ---------- |
|
|
| Normal completion | `defer` triggers → Write executes | ✅ |
|
|
| User clicks stop | `defer` triggers → Write executes | ✅ |
|
|
| LLM timeout | `defer` triggers → Write executes | ✅ |
|
|
| Tool failure | `defer` triggers → Write executes | ✅ |
|
|
| Network disconnect | `defer` triggers → Write executes | ✅ |
|
|
| Process crash | Service is down, user must retry | N/A |
|
|
|
|
**Note**: Process crash is a catastrophic failure handled at infrastructure level, not application level.
|
|
|
|
### Write Count Comparison
|
|
|
|
For a typical request: user input → hook_create → llm → tool → hook_next → 5 messages
|
|
|
|
| Strategy | Database Writes | Notes |
|
|
| ------------------------- | --------------- | --------------------- |
|
|
| Write per operation | 1 + 5 + 5 = 11 | One write per step |
|
|
| **Single-write strategy** | **1** | Exit only (via defer) |
|
|
|
|
### Implementation
|
|
|
|
````go
|
|
func (ast *Assistant) Stream(ctx, inputMessages, options) {
|
|
// ========== Memory Buffers ==========
|
|
messageBuffer := NewMessageBuffer()
|
|
stepBuffer := NewStepBuffer()
|
|
|
|
// Buffer user input message (not written yet)
|
|
userMsg := createUserMessage(ctx, inputMessages)
|
|
messageBuffer.Add(userMsg)
|
|
|
|
// Track current step for error handling
|
|
var currentStep *Step
|
|
|
|
defer func() {
|
|
// ========== Single Write: Exit (always executes) ==========
|
|
// Determine final status for incomplete steps
|
|
finalStatus := "completed"
|
|
if ctx.IsInterrupted() {
|
|
finalStatus = "interrupted"
|
|
}
|
|
if r := recover(); r != nil {
|
|
finalStatus = "failed"
|
|
}
|
|
|
|
// Update status of any incomplete step
|
|
if currentStep != nil && currentStep.Status == "running" {
|
|
currentStep.Status = finalStatus
|
|
}
|
|
|
|
// Batch write all buffered messages (user input + assistant responses)
|
|
chatStore.SaveMessages(ctx.ChatID, messageBuffer.GetAll())
|
|
|
|
// Only save steps on error/interrupt (not on success)
|
|
if finalStatus != "completed" {
|
|
chatStore.SaveResume(stepBuffer.GetAll())
|
|
}
|
|
|
|
// Update token usage in OpenAPI request record
|
|
if ctx.RequestID != "" && completionResponse != nil {
|
|
request.UpdateTokenUsage(
|
|
ctx.RequestID,
|
|
completionResponse.Usage.PromptTokens,
|
|
completionResponse.Usage.CompletionTokens,
|
|
)
|
|
}
|
|
}()
|
|
|
|
// ========== Execution (all in memory) ==========
|
|
// Note: request_id = ctx.RequestID (from OpenAPI middleware)
|
|
|
|
// hook_create
|
|
currentStep = stepBuffer.Add(createStep(ctx, "hook_create", "running", input, nil))
|
|
createResponse := ast.HookScript.Create(...)
|
|
currentStep.Output = createResponse
|
|
currentStep.Status = "completed"
|
|
|
|
// llm
|
|
currentStep = stepBuffer.Add(createStep(ctx, "llm", "running", messages, nil))
|
|
completionResponse := ast.executeLLMStream(...)
|
|
currentStep.Output = completionResponse
|
|
currentStep.Status = "completed"
|
|
|
|
// tool (if any)
|
|
for _, toolCall := range completionResponse.ToolCalls {
|
|
currentStep = stepBuffer.Add(createStep(ctx, "tool", "running", toolCall, nil))
|
|
result := executeToolCall(toolCall)
|
|
currentStep.Output = result
|
|
currentStep.Status = "completed"
|
|
}
|
|
|
|
// hook_next
|
|
currentStep = stepBuffer.Add(createStep(ctx, "hook_next", "running", payload, nil))
|
|
nextResponse := ast.HookScript.Next(...)
|
|
currentStep.Output = nextResponse
|
|
currentStep.Status = "completed"
|
|
currentStep = nil // All done
|
|
|
|
// Messages are automatically buffered via ctx.Send()
|
|
}
|
|
|
|
// createResumeRecord creates a resume record with context information
|
|
// Only called when request fails or is interrupted
|
|
func createResumeRecord(ctx *Context, stepType, status string, input, output interface{}, err error) *Resume {
|
|
// Capture Space snapshot for recovery
|
|
var spaceSnapshot map[string]interface{}
|
|
if ctx.Space != nil {
|
|
spaceSnapshot = ctx.Space.Snapshot() // Get all key-value pairs
|
|
}
|
|
|
|
errorMsg := ""
|
|
if err != nil {
|
|
errorMsg = err.Error()
|
|
}
|
|
|
|
return &Resume{
|
|
ResumeID: generateID(),
|
|
ChatID: ctx.ChatID, // ChatID
|
|
RequestID: ctx.RequestID, // From OpenAPI middleware
|
|
AssistantID: ctx.AssistantID,
|
|
StackID: ctx.Stack.ID,
|
|
StackParentID: ctx.Stack.ParentID,
|
|
StackDepth: ctx.Stack.Depth,
|
|
Type: stepType,
|
|
Status: status, // "failed" or "interrupted"
|
|
Input: input,
|
|
Output: output,
|
|
SpaceSnapshot: spaceSnapshot, // Shared space data for recovery
|
|
Error: errorMsg,
|
|
Sequence: nextSequence(),
|
|
}
|
|
}
|
|
|
|
## API Interface
|
|
|
|
### ChatStore Interface
|
|
|
|
```go
|
|
// ChatStore defines the chat storage interface
|
|
// Provides operations for chat, message, and resume management
|
|
type ChatStore interface {
|
|
// ==========================================================================
|
|
// Chat Management
|
|
// ==========================================================================
|
|
|
|
// CreateChat creates a new chat session
|
|
CreateChat(chat *Chat) error
|
|
|
|
// GetChat retrieves a single chat by ID
|
|
GetChat(chatID string) (*Chat, error)
|
|
|
|
// UpdateChat updates chat fields
|
|
UpdateChat(chatID string, updates map[string]interface{}) error
|
|
|
|
// DeleteChat deletes a chat and its associated messages
|
|
DeleteChat(chatID string) error
|
|
|
|
// ListChats retrieves a paginated list of chats with optional grouping
|
|
ListChats(filter ChatFilter) (*ChatList, error)
|
|
|
|
// ==========================================================================
|
|
// Message Management
|
|
// ==========================================================================
|
|
|
|
// SaveMessages batch saves messages for a chat
|
|
// This is the primary write method - messages are buffered during execution
|
|
// and batch-written at the end of a request
|
|
SaveMessages(chatID string, messages []*Message) error
|
|
|
|
// GetMessages retrieves messages for a chat with filtering
|
|
GetMessages(chatID string, filter MessageFilter) ([]*Message, error)
|
|
|
|
// UpdateMessage updates a single message
|
|
UpdateMessage(messageID string, updates map[string]interface{}) error
|
|
|
|
// DeleteMessages deletes specific messages from a chat
|
|
DeleteMessages(chatID string, messageIDs []string) error
|
|
|
|
// ==========================================================================
|
|
// Resume Management (only called on failure/interrupt)
|
|
// ==========================================================================
|
|
|
|
// SaveResume batch saves resume records
|
|
// Only called when request is interrupted or failed
|
|
SaveResume(records []*Resume) error
|
|
|
|
// GetResume retrieves all resume records for a chat
|
|
GetResume(chatID string) ([]*Resume, error)
|
|
|
|
// GetLastResume retrieves the last (most recent) resume record for a chat
|
|
GetLastResume(chatID string) (*Resume, error)
|
|
|
|
// GetResumeByStackID retrieves resume records for a specific stack
|
|
GetResumeByStackID(stackID string) ([]*Resume, error)
|
|
|
|
// GetStackPath returns the stack path from root to the given stack
|
|
// Returns: [root_stack_id, ..., current_stack_id]
|
|
GetStackPath(stackID string) ([]string, error)
|
|
|
|
// DeleteResume deletes all resume records for a chat
|
|
// Called after successful resume to clean up
|
|
DeleteResume(chatID string) error
|
|
}
|
|
|
|
// AssistantStore defines the assistant storage interface
|
|
// Separated from ChatStore for clearer responsibility
|
|
type AssistantStore interface {
|
|
// SaveAssistant saves assistant information
|
|
SaveAssistant(assistant *AssistantModel) (string, error)
|
|
|
|
// UpdateAssistant updates assistant fields
|
|
UpdateAssistant(assistantID string, updates map[string]interface{}) error
|
|
|
|
// DeleteAssistant deletes an assistant
|
|
DeleteAssistant(assistantID string) error
|
|
|
|
// GetAssistants retrieves a paginated list of assistants with filtering
|
|
GetAssistants(filter AssistantFilter, locale ...string) (*AssistantList, error)
|
|
|
|
// GetAssistantTags retrieves all unique tags from assistants with filtering
|
|
GetAssistantTags(filter AssistantFilter, locale ...string) ([]Tag, error)
|
|
|
|
// GetAssistant retrieves a single assistant by ID
|
|
GetAssistant(assistantID string, fields []string, locale ...string) (*AssistantModel, error)
|
|
|
|
// DeleteAssistants deletes assistants based on filter conditions
|
|
DeleteAssistants(filter AssistantFilter) (int64, error)
|
|
}
|
|
|
|
// Store combines ChatStore and AssistantStore interfaces
|
|
// This is the main interface for the storage layer
|
|
type Store interface {
|
|
ChatStore
|
|
AssistantStore
|
|
}
|
|
|
|
// SpaceStore defines the interface for Space snapshot operations
|
|
// Note: Space itself uses plan.Space interface, this is for persistence
|
|
type SpaceStore interface {
|
|
// Snapshot returns all key-value pairs in the space
|
|
Snapshot() map[string]interface{}
|
|
|
|
// Restore sets multiple key-value pairs from a snapshot
|
|
Restore(data map[string]interface{}) error
|
|
}
|
|
```
|
|
|
|
### Data Structures
|
|
|
|
```go
|
|
// Chat represents a chat session
|
|
type Chat struct {
|
|
ChatID string `json:"chat_id"`
|
|
Title string `json:"title,omitempty"`
|
|
AssistantID string `json:"assistant_id"`
|
|
LastConnector string `json:"last_connector,omitempty"` // Last used connector ID (updated on each message)
|
|
LastMode string `json:"last_mode,omitempty"` // Last used chat mode (updated on each message)
|
|
Status string `json:"status"` // "active" or "archived"
|
|
Public bool `json:"public"` // Whether shared across all teams
|
|
Share string `json:"share"` // "private" or "team"
|
|
Sort int `json:"sort"` // Sort order for display
|
|
LastMessageAt *time.Time `json:"last_message_at,omitempty"`
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// Message represents a chat message
|
|
type Message struct {
|
|
MessageID string `json:"message_id"`
|
|
ChatID string `json:"chat_id"`
|
|
RequestID string `json:"request_id,omitempty"`
|
|
Role string `json:"role"` // "user" or "assistant"
|
|
Type string `json:"type"` // "text", "image", "loading", "tool_call", "retrieval", etc.
|
|
Props map[string]interface{} `json:"props"`
|
|
BlockID string `json:"block_id,omitempty"`
|
|
ThreadID string `json:"thread_id,omitempty"`
|
|
AssistantID string `json:"assistant_id,omitempty"`
|
|
Connector string `json:"connector,omitempty"` // Connector ID used for this message
|
|
Mode string `json:"mode,omitempty"` // Chat mode used for this message (chat or task)
|
|
Sequence int `json:"sequence"`
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// Resume represents an execution state for recovery
|
|
// Only stored when request is interrupted or failed
|
|
type Resume struct {
|
|
ResumeID string `json:"resume_id"`
|
|
ChatID string `json:"chat_id"`
|
|
RequestID string `json:"request_id"`
|
|
AssistantID string `json:"assistant_id"`
|
|
StackID string `json:"stack_id"`
|
|
StackParentID string `json:"stack_parent_id,omitempty"`
|
|
StackDepth int `json:"stack_depth"`
|
|
Type string `json:"type"` // "input", "hook_create", "llm", "tool", "hook_next", "delegate"
|
|
Status string `json:"status"` // "failed" or "interrupted"
|
|
Input map[string]interface{} `json:"input,omitempty"`
|
|
Output map[string]interface{} `json:"output,omitempty"`
|
|
SpaceSnapshot map[string]interface{} `json:"space_snapshot,omitempty"` // Shared space data for recovery
|
|
Error string `json:"error,omitempty"`
|
|
Sequence int `json:"sequence"`
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// ResumeStatus constants
|
|
const (
|
|
ResumeStatusFailed = "failed"
|
|
ResumeStatusInterrupted = "interrupted"
|
|
)
|
|
|
|
// ResumeType constants
|
|
const (
|
|
ResumeTypeInput = "input"
|
|
ResumeTypeHookCreate = "hook_create"
|
|
ResumeTypeLLM = "llm"
|
|
ResumeTypeTool = "tool"
|
|
ResumeTypeHookNext = "hook_next"
|
|
ResumeTypeDelegate = "delegate"
|
|
)
|
|
```
|
|
|
|
### Filter Structures
|
|
|
|
```go
|
|
// ChatFilter for listing chats
|
|
type ChatFilter struct {
|
|
// Permission filters (direct filtering on Yao permission fields)
|
|
UserID string `json:"user_id,omitempty"` // Filter by __yao_created_by
|
|
TeamID string `json:"team_id,omitempty"` // Filter by __yao_team_id
|
|
|
|
// Business filters
|
|
AssistantID string `json:"assistant_id,omitempty"`
|
|
Status string `json:"status,omitempty"`
|
|
Keywords string `json:"keywords,omitempty"`
|
|
|
|
// Time range filter
|
|
StartTime *time.Time `json:"start_time,omitempty"` // Filter chats after this time
|
|
EndTime *time.Time `json:"end_time,omitempty"` // Filter chats before this time
|
|
TimeField string `json:"time_field,omitempty"` // Field for time filter: "created_at" or "last_message_at" (default)
|
|
|
|
// Sorting
|
|
OrderBy string `json:"order_by,omitempty"` // Field to sort by (default: "last_message_at")
|
|
Order string `json:"order,omitempty"` // Sort order: "desc" (default) or "asc"
|
|
|
|
// Response format
|
|
GroupBy string `json:"group_by,omitempty"` // "time" for time-based groups, empty for flat list
|
|
|
|
// Pagination
|
|
Page int `json:"page,omitempty"`
|
|
PageSize int `json:"pagesize,omitempty"`
|
|
|
|
// Advanced permission filter (not serialized)
|
|
// Use for complex conditions like: (created_by = user OR team_id = team)
|
|
QueryFilter func(query.Query) `json:"-"`
|
|
}
|
|
|
|
// MessageFilter for listing messages
|
|
type MessageFilter struct {
|
|
RequestID string `json:"request_id,omitempty"`
|
|
Role string `json:"role,omitempty"`
|
|
BlockID string `json:"block_id,omitempty"`
|
|
ThreadID string `json:"thread_id,omitempty"`
|
|
Type string `json:"type,omitempty"`
|
|
Limit int `json:"limit,omitempty"`
|
|
Offset int `json:"offset,omitempty"`
|
|
}
|
|
|
|
// ChatList paginated response with time-based grouping
|
|
type ChatList struct {
|
|
Data []*Chat `json:"data"`
|
|
Groups []*ChatGroup `json:"groups,omitempty"` // Time-based groups for UI display
|
|
Page int `json:"page"`
|
|
PageSize int `json:"pagesize"`
|
|
PageCount int `json:"pagecount"`
|
|
Total int `json:"total"`
|
|
}
|
|
|
|
// ChatGroup represents a time-based group of chats
|
|
type ChatGroup struct {
|
|
Label string `json:"label"` // "Today", "Yesterday", "This Week", "This Month", "Earlier"
|
|
Key string `json:"key"` // "today", "yesterday", "this_week", "this_month", "earlier"
|
|
Chats []*Chat `json:"chats"` // Chats in this group
|
|
Count int `json:"count"` // Number of chats in group
|
|
}
|
|
```
|
|
|
|
## Usage Examples
|
|
|
|
### 1. Complete Message Storage Example
|
|
|
|
A typical conversation with various message types stored in `agent_message`:
|
|
|
|
```
|
|
User: "What's the weather in SF? Also show me a chart."
|
|
|
|
Timeline (user input → hook_create → llm → tool → hook_next):
|
|
1. User sends input
|
|
2. Create hook shows loading state
|
|
3. LLM thinks and calls tool
|
|
4. Tool executes and returns result
|
|
5. Next hook generates text response and image chart
|
|
```
|
|
|
|
**Stored Messages:**
|
|
|
|
```json
|
|
[
|
|
// 1. User input (role=user, type=user_input)
|
|
{
|
|
"message_id": "msg_001",
|
|
"chat_id": "chat_123",
|
|
"request_id": "req_abc",
|
|
"role": "user",
|
|
"type": "user_input",
|
|
"props": {
|
|
"content": "What's the weather in SF? Also show me a chart.",
|
|
"role": "user"
|
|
},
|
|
"sequence": 1
|
|
},
|
|
|
|
// 2. Loading state from Create hook (role=assistant, type=loading)
|
|
{
|
|
"message_id": "msg_002",
|
|
"chat_id": "chat_123",
|
|
"request_id": "req_abc",
|
|
"role": "assistant",
|
|
"type": "loading",
|
|
"props": {
|
|
"message": "Searching knowledge base..."
|
|
},
|
|
"block_id": "B1",
|
|
"assistant_id": "weather_assistant",
|
|
"sequence": 2
|
|
},
|
|
|
|
// 3. LLM thinking process (role=assistant, type=thinking)
|
|
{
|
|
"message_id": "msg_003",
|
|
"chat_id": "chat_123",
|
|
"request_id": "req_abc",
|
|
"role": "assistant",
|
|
"type": "thinking",
|
|
"props": {
|
|
"content": "User wants weather info for San Francisco. I should use the get_weather tool..."
|
|
},
|
|
"block_id": "B2",
|
|
"assistant_id": "weather_assistant",
|
|
"sequence": 3
|
|
},
|
|
|
|
// 4. LLM tool call (role=assistant, type=tool_call)
|
|
{
|
|
"message_id": "msg_004",
|
|
"chat_id": "chat_123",
|
|
"request_id": "req_abc",
|
|
"role": "assistant",
|
|
"type": "tool_call",
|
|
"props": {
|
|
"id": "call_weather_001",
|
|
"name": "get_weather",
|
|
"arguments": "{\"location\": \"San Francisco\", \"unit\": \"celsius\"}"
|
|
},
|
|
"block_id": "B2",
|
|
"assistant_id": "weather_assistant",
|
|
"sequence": 4
|
|
},
|
|
|
|
// 5. Tool result from Next hook (role=assistant, type=text, with tool metadata)
|
|
{
|
|
"message_id": "msg_005",
|
|
"chat_id": "chat_123",
|
|
"request_id": "req_abc",
|
|
"role": "assistant",
|
|
"type": "text",
|
|
"props": {
|
|
"content": "The weather in San Francisco is currently **18°C** and sunny with 65% humidity. Perfect weather for outdoor activities!"
|
|
},
|
|
"block_id": "B3",
|
|
"metadata": {
|
|
"tool_call_id": "call_weather_001",
|
|
"tool_name": "get_weather"
|
|
},
|
|
"assistant_id": "weather_assistant",
|
|
"sequence": 5
|
|
},
|
|
|
|
// 6. Chart image from Next hook (role=assistant, type=image)
|
|
{
|
|
"message_id": "msg_006",
|
|
"chat_id": "chat_123",
|
|
"request_id": "req_abc",
|
|
"role": "assistant",
|
|
"type": "image",
|
|
"props": {
|
|
"url": "https://charts.example.com/weather_sf.png",
|
|
"alt": "San Francisco 7-day weather forecast",
|
|
"width": 800,
|
|
"height": 400
|
|
},
|
|
"block_id": "B3",
|
|
"assistant_id": "weather_assistant",
|
|
"sequence": 6
|
|
}
|
|
]
|
|
```
|
|
|
|
**Streaming IDs (from `STREAMING.md`):**
|
|
|
|
During streaming, messages include additional fields for real-time delivery:
|
|
|
|
| Field | Purpose | Stored? |
|
|
| ------------ | ------------------------------ | ------- |
|
|
| `chunk_id` | Deduplication, ordering, debug | ❌ No |
|
|
| `message_id` | Delta merge target | ✅ Yes |
|
|
| `block_id` | UI block/section grouping | ✅ Yes |
|
|
| `thread_id` | Concurrent stream distinction | ✅ Yes |
|
|
| `delta` | Whether this is a delta chunk | ❌ No |
|
|
| `delta_path` | Path for delta merge | ❌ No |
|
|
|
|
**Note:** `chunk_id`, `delta`, and `delta_path` are transient streaming control fields and are NOT stored. Only the final merged content is persisted.
|
|
|
|
### 2. Error Message Storage
|
|
|
|
When errors occur, they are stored as `error` type:
|
|
|
|
```json
|
|
{
|
|
"message_id": "msg_err_001",
|
|
"chat_id": "chat_123",
|
|
"request_id": "req_abc",
|
|
"role": "assistant",
|
|
"type": "error",
|
|
"props": {
|
|
"message": "Failed to connect to weather service",
|
|
"code": "SERVICE_UNAVAILABLE",
|
|
"details": "Connection timeout after 30 seconds"
|
|
},
|
|
"block_id": "B2",
|
|
"assistant_id": "weather_assistant",
|
|
"sequence": 5
|
|
}
|
|
```
|
|
|
|
### 3. Action Message Storage (CUI clients)
|
|
|
|
System actions are stored but only processed by CUI clients:
|
|
|
|
```json
|
|
{
|
|
"message_id": "msg_action_001",
|
|
"chat_id": "chat_123",
|
|
"request_id": "req_abc",
|
|
"role": "assistant",
|
|
"type": "action",
|
|
"props": {
|
|
"name": "open_panel",
|
|
"payload": {
|
|
"panel_id": "weather_details",
|
|
"location": "San Francisco"
|
|
}
|
|
},
|
|
"block_id": "B2",
|
|
"assistant_id": "weather_assistant",
|
|
"sequence": 6
|
|
}
|
|
```
|
|
|
|
### 4. Audio/Video Message Storage
|
|
|
|
Multimedia content storage:
|
|
|
|
```json
|
|
// Audio message
|
|
{
|
|
"message_id": "msg_audio_001",
|
|
"chat_id": "chat_123",
|
|
"role": "assistant",
|
|
"type": "audio",
|
|
"props": {
|
|
"url": "https://storage.example.com/audio/response.mp3",
|
|
"format": "mp3",
|
|
"duration": 45.5,
|
|
"transcript": "Here's the weather forecast for today...",
|
|
"controls": true
|
|
},
|
|
"sequence": 7
|
|
}
|
|
|
|
// Video message
|
|
{
|
|
"message_id": "msg_video_001",
|
|
"chat_id": "chat_123",
|
|
"role": "assistant",
|
|
"type": "video",
|
|
"props": {
|
|
"url": "https://storage.example.com/video/weather_report.mp4",
|
|
"format": "mp4",
|
|
"thumbnail": "https://storage.example.com/video/weather_report_thumb.jpg",
|
|
"duration": 120.0,
|
|
"width": 1280,
|
|
"height": 720,
|
|
"controls": true
|
|
},
|
|
"sequence": 8
|
|
}
|
|
```
|
|
|
|
### 5. Load Chat History
|
|
|
|
```go
|
|
// Example 1: Filter by user (simple permission check)
|
|
chats, _ := chatStore.ListChats(ChatFilter{
|
|
UserID: "user123", // Filters by __yao_created_by
|
|
Status: "active",
|
|
OrderBy: "last_message_at",
|
|
Order: "desc",
|
|
Page: 1,
|
|
PageSize: 20,
|
|
})
|
|
// Response: chats.Data = [...], chats.Groups = nil
|
|
|
|
// Example 2: Filter by team
|
|
chats, _ := chatStore.ListChats(ChatFilter{
|
|
TeamID: "team456", // Filters by __yao_team_id
|
|
Status: "active",
|
|
Page: 1,
|
|
PageSize: 20,
|
|
})
|
|
|
|
// Example 3: Filter by user AND team (both must match)
|
|
chats, _ := chatStore.ListChats(ChatFilter{
|
|
UserID: "user123",
|
|
TeamID: "team456",
|
|
Page: 1,
|
|
PageSize: 20,
|
|
})
|
|
|
|
// Example 4: Complex permission filter (user OR team) using QueryFilter
|
|
chats, _ := chatStore.ListChats(ChatFilter{
|
|
Page: 1,
|
|
PageSize: 20,
|
|
QueryFilter: func(qb query.Query) {
|
|
qb.Where(func(sub query.Query) {
|
|
sub.Where("__yao_created_by", "user123").
|
|
OrWhere("__yao_team_id", "team456")
|
|
})
|
|
},
|
|
})
|
|
|
|
// Example 5: Grouped by time
|
|
chats, _ := chatStore.ListChats(ChatFilter{
|
|
UserID: "user123",
|
|
GroupBy: "time", // Enable time-based grouping
|
|
OrderBy: "last_message_at",
|
|
Order: "desc",
|
|
Page: 1,
|
|
PageSize: 20,
|
|
})
|
|
// Response includes time-based groups:
|
|
// chats.Groups = [
|
|
// { Key: "today", Label: "Today", Chats: [...], Count: 3 },
|
|
// { Key: "yesterday", Label: "Yesterday", Chats: [...], Count: 5 },
|
|
// { Key: "this_week", Label: "This Week", Chats: [...], Count: 8 },
|
|
// { Key: "this_month", Label: "This Month", Chats: [...], Count: 4 },
|
|
// { Key: "earlier", Label: "Earlier", Chats: [...], Count: 0 },
|
|
// ]
|
|
|
|
// Example 6: Filter by time range
|
|
startTime := time.Now().AddDate(0, 0, -7) // Last 7 days
|
|
chats, _ := chatStore.ListChats(ChatFilter{
|
|
UserID: "user123",
|
|
StartTime: &startTime,
|
|
TimeField: "last_message_at", // Filter by last message time
|
|
OrderBy: "last_message_at",
|
|
Order: "desc",
|
|
})
|
|
|
|
// Example 7: Filter specific date range
|
|
start := time.Date(2024, 12, 1, 0, 0, 0, 0, time.Local)
|
|
end := time.Date(2024, 12, 31, 23, 59, 59, 0, time.Local)
|
|
chats, _ := chatStore.ListChats(ChatFilter{
|
|
UserID: "user123",
|
|
StartTime: &start,
|
|
EndTime: &end,
|
|
TimeField: "created_at", // Filter by creation time
|
|
})
|
|
|
|
// Example 8: Combine permission with business filters
|
|
chats, _ := chatStore.ListChats(ChatFilter{
|
|
UserID: "user123",
|
|
TeamID: "team456",
|
|
AssistantID: "weather_assistant",
|
|
Status: "active",
|
|
Keywords: "weather",
|
|
Page: 1,
|
|
PageSize: 20,
|
|
})
|
|
|
|
// Get messages for a chat
|
|
messages, _ := chatStore.GetMessages("chat_123", MessageFilter{
|
|
Limit: 100,
|
|
})
|
|
|
|
// Return to frontend
|
|
return map[string]interface{}{
|
|
"chat": chat,
|
|
"messages": messages,
|
|
}
|
|
```
|
|
|
|
### 6. Resume from Interruption
|
|
|
|
```go
|
|
func (ast *Assistant) Resume(ctx *Context) error {
|
|
// 1. Find last resume record
|
|
record, _ := chatStore.GetLastResume(ctx.ChatID)
|
|
if record == nil {
|
|
return nil // Nothing to resume
|
|
}
|
|
|
|
// 2. Restore Space data from snapshot
|
|
if record.SpaceSnapshot != nil && ctx.Space != nil {
|
|
for key, value := range record.SpaceSnapshot {
|
|
ctx.Space.Set(key, value)
|
|
}
|
|
}
|
|
|
|
// 3. Check if this is an A2A nested call
|
|
if record.StackDepth > 0 {
|
|
// Need to rebuild the call stack
|
|
return ast.ResumeNestedCall(ctx, record)
|
|
}
|
|
|
|
// 4. Resume based on step type
|
|
var err error
|
|
switch record.Type {
|
|
case "llm":
|
|
// Re-execute LLM call with saved input
|
|
messages := record.Input["messages"].([]Message)
|
|
err = ast.executeLLMStream(ctx, messages, ...)
|
|
|
|
case "tool":
|
|
// Retry tool call
|
|
err = ast.retryToolCall(ctx, record)
|
|
|
|
case "hook_next":
|
|
// Re-execute hook
|
|
err = ast.executeHookNext(ctx, record.Input)
|
|
|
|
case "delegate":
|
|
// Resume delegated agent call
|
|
agentID := record.Input["agent_id"].(string)
|
|
messages := record.Input["messages"].([]Message)
|
|
err = ast.delegateToAgent(ctx, agentID, messages)
|
|
}
|
|
|
|
// 5. Clean up resume records on success
|
|
if err == nil {
|
|
chatStore.DeleteResume(ctx.ChatID)
|
|
}
|
|
|
|
return err
|
|
}
|
|
```
|
|
|
|
### 7. Resume A2A Nested Calls
|
|
|
|
For agent-to-agent (A2A) recursive calls, the stack information is essential for proper recovery.
|
|
|
|
```go
|
|
func (ast *Assistant) ResumeNestedCall(ctx *Context, step *Step) error {
|
|
// 1. Rebuild the call stack from root to interrupted point
|
|
stackPath, _ := chatStore.GetStackPath(step.StackID)
|
|
// stackPath: [root_stack_id, parent_stack_id, ..., current_stack_id]
|
|
|
|
// 2. Get all steps for each stack level
|
|
for _, stackID := range stackPath {
|
|
steps, _ := chatStore.GetStepsByStackID(stackID)
|
|
// Restore context for each level
|
|
}
|
|
|
|
// 3. Resume from the interrupted assistant
|
|
targetAssistant := assistant.Select(step.AssistantID)
|
|
return targetAssistant.Stream(ctx, step.Input["messages"], ...)
|
|
}
|
|
```
|
|
|
|
### 8. Handle Interruption
|
|
|
|
Interruption is handled automatically by the `defer` block in the two-write strategy. When `ctx.IsInterrupted()` returns true, the status is set to `interrupted` and all buffered data is saved.
|
|
|
|
```go
|
|
// Inside the defer block (see Write Strategy - Implementation)
|
|
if ctx.IsInterrupted() {
|
|
status = "interrupted"
|
|
}
|
|
// Then batch write all buffered messages and steps
|
|
```
|
|
|
|
## A2A (Agent-to-Agent) Call Example
|
|
|
|
When Assistant A delegates to Assistant B, the step records look like:
|
|
|
|
```
|
|
Request: User asks "analyze this data and visualize it"
|
|
|
|
Step Records:
|
|
┌─────┬─────────────┬─────────────┬──────────┬───────┬───────┬─────────────┬─────────────────────────────┐
|
|
│ seq │ assistant │ stack_id │ parent │ depth │ type │ status │ space_snapshot │
|
|
├─────┼─────────────┼─────────────┼──────────┼───────┼───────┼─────────────┼─────────────────────────────┤
|
|
│ 1 │ analyzer │ stk_001 │ null │ 0 │ input │ completed │ {} │
|
|
│ 2 │ analyzer │ stk_001 │ null │ 0 │ llm │ completed │ {} │
|
|
│ 3 │ analyzer │ stk_001 │ null │ 0 │ delegate │ running │ {"choose_prompt": "query"} │ ← Space data set before delegate
|
|
│ 4 │ visualizer │ stk_002 │ stk_001 │ 1 │ input │ completed │ {"choose_prompt": "query"} │
|
|
│ 5 │ visualizer │ stk_002 │ stk_001 │ 1 │ llm │ interrupted │ {"choose_prompt": "query"} │ ← interrupted here
|
|
└─────┴─────────────┴─────────────┴──────────┴───────┴───────┴─────────────┴─────────────────────────────┘
|
|
|
|
Resume Flow:
|
|
1. Find step with status="interrupted" → step 5
|
|
2. Restore Space from space_snapshot: {"choose_prompt": "query"}
|
|
3. Check stack_depth=1 → nested call
|
|
4. Get stack path: [stk_001, stk_002]
|
|
5. Resume visualizer assistant with step 5's input
|
|
6. When visualizer completes, update step 3 (delegate) to completed
|
|
```
|
|
|
|
**Space Snapshot Use Case (from expense assistant):**
|
|
|
|
```typescript
|
|
// In Next hook, before delegating to another agent
|
|
ctx.space.Set("choose_prompt", "query");
|
|
return {
|
|
delegate: { agent_id: "expense", messages: payload.messages },
|
|
};
|
|
|
|
// If interrupted during delegate, Resume will:
|
|
// 1. Restore space_snapshot → ctx.space now has "choose_prompt": "query"
|
|
// 2. The delegated agent's Create hook can read: ctx.space.GetDel("choose_prompt")
|
|
```
|
|
|
|
## Concurrent Operations Storage
|
|
|
|
When an Agent makes parallel calls (e.g., multiple MCP tools, multiple sub-agents), messages use `block_id` and `thread_id` for grouping:
|
|
|
|
```
|
|
Main Agent concurrently calls 3 tasks:
|
|
├── Thread T1: Weather query (MCP)
|
|
├── Thread T2: News search (MCP)
|
|
├── Thread T3: Stock query (MCP)
|
|
└── Wait for all to complete, then summarize
|
|
```
|
|
|
|
**Stored Messages:**
|
|
|
|
```json
|
|
[
|
|
// All concurrent messages share the same block_id, different thread_id
|
|
// Messages may arrive in any order due to concurrency
|
|
|
|
// Thread T1: Weather result
|
|
{
|
|
"message_id": "msg_t1_001",
|
|
"chat_id": "chat_123",
|
|
"request_id": "req_abc",
|
|
"role": "assistant",
|
|
"type": "text",
|
|
"props": { "content": "Weather in SF: 18°C, sunny" },
|
|
"block_id": "B1",
|
|
"thread_id": "T1",
|
|
"assistant_id": "main_assistant",
|
|
"sequence": 2
|
|
},
|
|
|
|
// Thread T2: News result
|
|
{
|
|
"message_id": "msg_t2_001",
|
|
"chat_id": "chat_123",
|
|
"request_id": "req_abc",
|
|
"role": "assistant",
|
|
"type": "text",
|
|
"props": { "content": "Top news: AI breakthrough announced..." },
|
|
"block_id": "B1",
|
|
"thread_id": "T2",
|
|
"assistant_id": "main_assistant",
|
|
"sequence": 3
|
|
},
|
|
|
|
// Thread T3: Stock result
|
|
{
|
|
"message_id": "msg_t3_001",
|
|
"chat_id": "chat_123",
|
|
"request_id": "req_abc",
|
|
"role": "assistant",
|
|
"type": "text",
|
|
"props": { "content": "AAPL: $185.50 (+1.2%)" },
|
|
"block_id": "B1",
|
|
"thread_id": "T3",
|
|
"assistant_id": "main_assistant",
|
|
"sequence": 4
|
|
},
|
|
|
|
// After all threads complete, main agent summarizes (new block)
|
|
{
|
|
"message_id": "msg_summary",
|
|
"chat_id": "chat_123",
|
|
"request_id": "req_abc",
|
|
"role": "assistant",
|
|
"type": "text",
|
|
"props": {
|
|
"content": "Here's your daily briefing: The weather is great at 18°C..."
|
|
},
|
|
"block_id": "B2",
|
|
"thread_id": null,
|
|
"assistant_id": "main_assistant",
|
|
"sequence": 5
|
|
}
|
|
]
|
|
```
|
|
|
|
**Key Points:**
|
|
|
|
| Field | Concurrent Usage |
|
|
| ----------- | -------------------------------------------------- |
|
|
| `block_id` | Same for all parallel operations (B1) |
|
|
| `thread_id` | Different for each concurrent task (T1, T2, T3) |
|
|
| `sequence` | Reflects actual arrival order (may be interleaved) |
|
|
|
|
**Frontend Rendering:**
|
|
|
|
- Group messages by `block_id` for visual blocks
|
|
- Within a block, optionally group by `thread_id` to show parallel results
|
|
- Use `sequence` for chronological display
|
|
|
|
## HTTP API
|
|
|
|
The chat storage provides RESTful HTTP APIs for managing chat sessions and messages.
|
|
|
|
**Base Path:** `/v1/chat`
|
|
|
|
### Chat Sessions
|
|
|
|
| Method | Endpoint | Description |
|
|
|--------|----------|-------------|
|
|
| `GET` | `/sessions` | List chat sessions with pagination and filtering |
|
|
| `GET` | `/sessions/:chat_id` | Get a single chat session |
|
|
| `PUT` | `/sessions/:chat_id` | Update chat session (title, status, metadata) |
|
|
| `DELETE` | `/sessions/:chat_id` | Delete chat session |
|
|
| `GET` | `/sessions/:chat_id/messages` | Get messages for a chat session |
|
|
|
|
### List Chat Sessions
|
|
|
|
**Request:**
|
|
|
|
```
|
|
GET /v1/chat/sessions?page=1&pagesize=20&assistant_id=xxx&status=active&keywords=search&group_by=time
|
|
```
|
|
|
|
**Query Parameters:**
|
|
|
|
| Parameter | Type | Default | Description |
|
|
|-----------|------|---------|-------------|
|
|
| `page` | int | 1 | Page number |
|
|
| `pagesize` | int | 20 | Items per page (max 100) |
|
|
| `assistant_id` | string | - | Filter by assistant ID |
|
|
| `status` | string | - | Filter by status: `active`, `archived` |
|
|
| `keywords` | string | - | Search in title |
|
|
| `start_time` | RFC3339 | - | Filter chats after this time |
|
|
| `end_time` | RFC3339 | - | Filter chats before this time |
|
|
| `time_field` | string | `last_message_at` | Field for time filter: `created_at` or `last_message_at` |
|
|
| `order_by` | string | `last_message_at` | Sort field |
|
|
| `order` | string | `desc` | Sort order: `asc` or `desc` |
|
|
| `group_by` | string | - | Set to `time` for time-based grouping |
|
|
|
|
**Response:**
|
|
|
|
```json
|
|
{
|
|
"data": [
|
|
{
|
|
"chat_id": "chat_123",
|
|
"title": "Weather Query",
|
|
"assistant_id": "weather_assistant",
|
|
"status": "active",
|
|
"last_message_at": "2024-01-15T10:30:00Z",
|
|
"created_at": "2024-01-15T10:00:00Z"
|
|
}
|
|
],
|
|
"groups": [
|
|
{
|
|
"key": "today",
|
|
"label": "Today",
|
|
"chats": [...],
|
|
"count": 3
|
|
},
|
|
{
|
|
"key": "yesterday",
|
|
"label": "Yesterday",
|
|
"chats": [...],
|
|
"count": 5
|
|
}
|
|
],
|
|
"page": 1,
|
|
"pagesize": 20,
|
|
"pagecount": 5,
|
|
"total": 100
|
|
}
|
|
```
|
|
|
|
### Get Chat Session
|
|
|
|
**Request:**
|
|
|
|
```
|
|
GET /v1/chat/sessions/chat_123
|
|
```
|
|
|
|
**Response:**
|
|
|
|
```json
|
|
{
|
|
"chat_id": "chat_123",
|
|
"title": "Weather Query",
|
|
"assistant_id": "weather_assistant",
|
|
"last_connector": "deepseek.v3",
|
|
"last_mode": "chat",
|
|
"status": "active",
|
|
"public": false,
|
|
"share": "private",
|
|
"last_message_at": "2024-01-15T10:30:00Z",
|
|
"metadata": {},
|
|
"created_at": "2024-01-15T10:00:00Z",
|
|
"updated_at": "2024-01-15T10:30:00Z"
|
|
}
|
|
```
|
|
|
|
### Update Chat Session
|
|
|
|
**Request:**
|
|
|
|
```
|
|
PUT /v1/chat/sessions/chat_123
|
|
Content-Type: application/json
|
|
|
|
{
|
|
"title": "New Title",
|
|
"status": "archived",
|
|
"metadata": {"custom_field": "value"}
|
|
}
|
|
```
|
|
|
|
**Response:**
|
|
|
|
```json
|
|
{
|
|
"message": "Chat updated successfully",
|
|
"chat_id": "chat_123"
|
|
}
|
|
```
|
|
|
|
### Delete Chat Session
|
|
|
|
**Request:**
|
|
|
|
```
|
|
DELETE /v1/chat/sessions/chat_123
|
|
```
|
|
|
|
**Response:**
|
|
|
|
```json
|
|
{
|
|
"message": "Chat deleted successfully",
|
|
"chat_id": "chat_123"
|
|
}
|
|
```
|
|
|
|
### Get Chat Messages
|
|
|
|
**Request:**
|
|
|
|
```
|
|
GET /v1/chat/sessions/chat_123/messages?limit=100&offset=0&role=assistant&type=text
|
|
```
|
|
|
|
**Query Parameters:**
|
|
|
|
| Parameter | Type | Default | Description |
|
|
|-----------|------|---------|-------------|
|
|
| `request_id` | string | - | Filter by request ID |
|
|
| `role` | string | - | Filter by role: `user`, `assistant` |
|
|
| `block_id` | string | - | Filter by block ID |
|
|
| `thread_id` | string | - | Filter by thread ID |
|
|
| `type` | string | - | Filter by message type |
|
|
| `limit` | int | 100 | Max messages to return (max 1000) |
|
|
| `offset` | int | 0 | Offset for pagination |
|
|
| `locale` | string | - | Locale for assistant info (e.g., `zh-cn`, `en-us`). Falls back to `Accept-Language` header |
|
|
|
|
**Locale Resolution Priority:**
|
|
1. Query parameter `locale`
|
|
2. HTTP header `Accept-Language`
|
|
|
|
**Response:**
|
|
|
|
```json
|
|
{
|
|
"chat_id": "chat_123",
|
|
"messages": [
|
|
{
|
|
"message_id": "msg_001",
|
|
"chat_id": "chat_123",
|
|
"request_id": "req_abc",
|
|
"role": "user",
|
|
"type": "user_input",
|
|
"props": {
|
|
"content": "What's the weather?",
|
|
"role": "user"
|
|
},
|
|
"sequence": 1,
|
|
"created_at": "2024-01-15T10:00:00Z"
|
|
},
|
|
{
|
|
"message_id": "msg_002",
|
|
"chat_id": "chat_123",
|
|
"request_id": "req_abc",
|
|
"role": "assistant",
|
|
"type": "text",
|
|
"props": {
|
|
"content": "The weather in San Francisco is 18°C and sunny."
|
|
},
|
|
"block_id": "B1",
|
|
"assistant_id": "weather_assistant",
|
|
"sequence": 2,
|
|
"created_at": "2024-01-15T10:00:05Z"
|
|
}
|
|
],
|
|
"count": 2,
|
|
"assistants": {
|
|
"weather_assistant": {
|
|
"assistant_id": "weather_assistant",
|
|
"name": "Weather Assistant",
|
|
"avatar": "https://example.com/weather-avatar.png",
|
|
"description": "Get weather information for any location"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Note:** The `assistants` field contains localized assistant information (name, avatar, description) for all unique `assistant_id` values found in the messages. This allows the frontend to display assistant details without additional API calls. The locale is determined by the `locale` query parameter or `Accept-Language` header.
|
|
|
|
### Permission Filtering
|
|
|
|
All endpoints respect Yao's permission system:
|
|
|
|
| Constraint | Behavior |
|
|
|------------|----------|
|
|
| `OwnerOnly` | User can only access their own chats (`__yao_created_by` matches) |
|
|
| `TeamOnly` | User can access own chats OR team-shared chats (`share = "team"`) |
|
|
| No constraints | Full access (for admin users) |
|
|
|
|
**Permission Fields Used:**
|
|
|
|
- `__yao_created_by`: User who created the chat
|
|
- `__yao_team_id`: Team ID for team-level access
|
|
- `public`: Whether chat is public to all
|
|
- `share`: Sharing scope (`private` or `team`)
|
|
|
|
## Related Documents
|
|
|
|
- [OpenAPI Request Design](../../openapi/request/REQUEST_DESIGN.md) - Global request tracking, billing, rate limiting
|
|
- [Trace Module](../../trace/README.md) - Detailed execution tracing for debugging
|
|
- [Agent Context](../context/README.md) - Context and message handling
|
|
````
|