Agent, session, message
Agent, session, message
Tổng quan
Thêm ba bảng miền thuộc sở hữu tenant mà harness chạy trên đó: agents (một cấu hình do user soạn: model, system prompt, allowlist các tool, config JSON), sessions (một luồng hội thoại thuộc về một agent), và messages (các dòng chỉ-thêm với block jsonb, một dòng cho mỗi message, được định địa chỉ bằng (session_id, seq)). Thêm các API CRUD cho cả ba và quy ước session key. Vẫn chưa có lời gọi LLM nào: giai đoạn này là nền lưu trữ mà vòng lặp sẽ ghi vào.
Yêu cầu
Chức năng
agents: do một user tạo trong tenant của họ; các trườngname,slug,model,provider,system_prompt,tools jsonb(allowlist),config jsonb,created_by.sessions: thuộc về một agent, mang mộtkeydễ đọc cho người, mộttitle, mộtstatus,created_by, và các bộ đếm dùng sau này cho compaction.created_bytồn tại để có thể thực thi khả năng nhìn thấy trong cùng tenant trên session và, kế thừa, trên các message và run của chúng.messages: chỉ-thêm;(session_id, seq)là duy nhất;role,blocks jsonb,provider_message_id,stop_reason,usage jsonb,created_at. Không cập nhật, không xóa ngoài việc xóa cả session.- API:
POST/GET/PATCH/DELETE /v1/agents,POST/GET/DELETE /v1/sessions,GET /v1/sessions/{id}/messages(phân trang theoseq),POST /v1/sessions/{id}/messagesđể thêm trực tiếp một message của user. - Việc thêm message cấp phát
seqmột cách nguyên tử dưới đồng thời (không bắt buộc không có khoảng trống, không được trùng). - Quy ước session key:
agent:{agent_slug}:{source}:{external_id};GET /v1/sessions?key=phân giải hoặc endpoint tạo upsert theo key. - Ma trận phân quyền theo từng route cho giai đoạn này, được thêm vào `internal/server/authz.go`. "own" nghĩa là `created_by = principal.UserID`.
| Route | owner / admin | member |
|---|---|---|
POST /v1/agents | allow | allow (trở thành created_by) |
GET /v1/agents, GET /v1/agents/{id} | mọi agent trong tenant | chỉ agent của mình |
PATCH/DELETE /v1/agents/{id} | mọi agent trong tenant | chỉ agent của mình |
POST /v1/sessions | mọi agent trong tenant | chỉ agent của mình; session nhận created_by |
GET /v1/sessions, GET /v1/sessions/{id} | mọi session trong tenant | chỉ session của mình |
DELETE /v1/sessions/{id} | mọi session trong tenant | chỉ session của mình |
GET/POST /v1/sessions/{id}/messages | mọi session trong tenant | chỉ session của mình |
- Từ chối là 404, không phải 403, trên các route đọc, để một `member` không thể dò sự tồn tại của agent hay session của user khác. Predicate giới hạn phạm vi (`created_by = $n` cho member) nằm trong các method của store, không nằm trong handler, nên một handler mới không thể quên nó.
Phi chức năng
- Các case cô lập cross-tenant và cross-user trong cùng tenant được thêm cho
agents,sessions,messages(mộtmemberkhông thể đọc hay xóa agent hoặc session của user khác;owner/adminthì có thể). provider.Blockđược lưu và tải lại ổn định từng byte quajsonb, gồm cả signature của thinking.- Việc liệt kê message có index hỗ trợ:
(session_id, seq). - Xóa một agent kéo theo xóa các session và message của nó trong một transaction.
Kiến trúc
// internal/store/types.go (additions)
type Agent struct {
ID, TenantID, CreatedBy uuid.UUID
Name, Slug, Model, Provider, SystemPrompt string
Tools []string
Config json.RawMessage
CreatedAt, UpdatedAt time.Time
}
type Session struct {
ID, TenantID, AgentID, CreatedBy uuid.UUID // Red team: D2
Key, Title, Status string
LastSeq int
CreatedAt, UpdatedAt time.Time
}
type Message struct {
ID, TenantID, SessionID uuid.UUID
Seq int
Role provider.Role
Blocks []provider.Block
StopReason string
Usage json.RawMessage
CreatedAt time.Time
}
// internal/store/store.go (Scoped additions)
type Scoped interface {
// ...
Agents() AgentStore
Sessions() SessionStore
Messages() MessageStore
InTx(ctx context.Context, fn func(Scoped) error) error // scope preserved inside the tx
}
type MessageStore interface {
Append(ctx context.Context, sessionID uuid.UUID, msgs ...Message) ([]Message, error) // allocates seq
List(ctx context.Context, sessionID uuid.UUID, afterSeq, limit int) ([]Message, error)
History(ctx context.Context, sessionID uuid.UUID) ([]provider.Message, error) // loop-ready
}-- migrations/00002_agents_sessions_messages.sql
create table agents (
id uuid primary key,
tenant_id uuid not null references tenants(id) on delete cascade,
created_by uuid not null references users(id),
name text not null,
slug text not null,
provider text not null default 'anthropic',
model text not null,
system_prompt text not null default '',
tools jsonb not null default '[]'::jsonb,
config jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, slug)
);
create index agents_tenant_created_idx on agents (tenant_id, created_at desc);
create table sessions (
id uuid primary key,
tenant_id uuid not null references tenants(id) on delete cascade,
agent_id uuid not null references agents(id) on delete cascade,
created_by uuid not null references users(id), -- Red team: D2 — intra-tenant visibility
key text not null,
title text not null default '',
status text not null default 'active',
last_seq int not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, key)
);
create index sessions_tenant_agent_idx on sessions (tenant_id, agent_id, updated_at desc);
create index sessions_tenant_creator_idx on sessions (tenant_id, created_by, updated_at desc); -- Red team: D2
create table messages (
id uuid primary key,
tenant_id uuid not null references tenants(id) on delete cascade,
session_id uuid not null references sessions(id) on delete cascade,
seq int not null,
role text not null,
blocks jsonb not null,
stop_reason text not null default '',
usage jsonb,
created_at timestamptz not null default now(),
unique (session_id, seq)
);
create index messages_tenant_session_seq_idx on messages (tenant_id, session_id, seq);Instrumentation
- Span: mỗi method của store chạy bên trong span của bên gọi và tự động tạo các span pgx CLIENT con (tracer của Phase 2).
MessageStore.AppendvàInTxthêm các span INTERNAL tường minh (store.messages.append,store.tx) vớihango.session.id,hango.message.countvà, vớiInTx, số câu lệnh, vì việc cấp phát sequence dưới row lock là nơi duy nhất mà tranh chấp sẽ lộ ra. - Attribute: các handler đặt
hango.agent.idvàhango.session.idlên request span ngay khi chúng được phân giải;internal/telemetry.ContextLoggerđược mở rộng để cùng các id đó xuất hiện trên mọi dòng log. - Metric: counter
hango.messages.appended(attributerole), gaugehango.sessions.active, histogramhango.message.blocks(số block trên mỗi message). - Trường log: mỗi handler ghi một dòng hoàn tất với
hango.agent.id,hango.session.id,seq range, độ trễ.
Các file liên quan
Tạo mới
migrations/00002_agents_sessions_messages.sql.internal/store/pg/queries/agents.sql,internal/store/pg/queries/sessions.sql,internal/store/pg/queries/messages.sql, và các file*.sql.gomàmake generateghi ra từ chúng (được commit, không bao giờ sửa tay). Các query theo pattern của phase 2.1: mặc định dùng sqlc, pgx viết tay chỉ cho một query sqlc không biểu đạt được, kèm một comment nêu giới hạn đó.internal/store/pg/agent.go,internal/store/pg/session.go,internal/store/pg/message.go— các mapper mỏng bọc trên các query được sinh ra, như tronguser.go.internal/store/sessionkey.go—BuildKey(agentSlug, source, externalID)vàParseKey.internal/server/handlers_agents.go,internal/server/handlers_sessions.go,internal/server/handlers_messages.go.internal/server/dto.go— các struct request/response và validation (không đưa kiểu miền ra wire).internal/store/pg/agent_test.go,session_test.go,message_test.go(integration).docs/learning/04-sessions-and-messages.md.
Sửa đổi
internal/store/store.go,internal/store/types.go,internal/store/pg/scoped.go— các sub-store mới vàInTx.internal/store/pg/tenancy_test.go— thêm các caseagents,sessions,messages.internal/server/server.go— mount các route mới lên group tenant-authed.docs/system-architecture.md,docs/learning/README.md.
Các bước triển khai
- Viết
migrations/00002_agents_sessions_messages.sqlvới DDL ở trên và các lệnh drop-- +goose Downtương ứng theo thứ tự ngược. - Mở rộng
internal/store/types.govớiAgent,Session,Message.Message.Blockslà[]provider.Block— package store được phép importprovider(một chiều;providerkhông bao giờ importstore). - Cài
InTxtronginternal/store/pg/tx.go: bắt đầu mộtpgx.Tx, dựng mộtscopedmới gắn với cùngScopetrên querier của tx, chạy callback, commit hoặc rollback. Đây là cách duy nhất để các giai đoạn sau có tính nguyên tử nhiều câu lệnh mà không mất scoping. - Viết
queries/agents.sqlvà cài CRUD choAgentStoredưới dạng các mapper trên các method được sinh ra, theouser.go. Mọi câu lệnh lấy@tenant_idtừ scope.ToolsvàConfiglàjsonb, nên chúng đến dưới dạngjson.RawMessagequa override sẵn có. Chạymake generatevàmake sqlc-check. - Cài
SessionStore:Create(đặtcreated_bytừ scope;ON CONFLICT (tenant_id, key) DO NOTHING+ select lại để upsert theo key),Get,ListByAgent,Delete. Mọi query đọc và thay đổi đều mang(sqlc.narg('only_user')::uuid is null or created_by = sqlc.narg('only_user')), predicate member của phase 2.1. Đối số đến từ một helper (visibility(scope), thay choownUserIDcủa phase 2.1) dùng chung bởi các store agent, session, message và run về sau, nên chỉ có một nơi để kiểm toán: nil choownervàadmin, user id của người gọi chomember.visibility(scope)trả về lỗi với bất kỳ role nào ngoàiowner,adminhoặcmember, gồm cả role rỗng, để một scope dựng mà không có role sẽ không đọc được gì thay vì đọc cả tenant. - Cài
MessageStore.Append: bên trongInTx, mộtReserveSeq :oneđược sinh ra (UPDATE sessions SET last_seq = last_seq + @count ... RETURNING last_seq) dành một khối số sequence liên tục dưới row lock, rồi một insert được sinh ra ghi mọi message. sqlc không biểu đạt được một danh sáchVALUEScó độ dài thay đổi, nên insert đọc các mảng song song:insert into messages (...) select @tenant_id::uuid, @session_id::uuid, s, r, b from unnest(@seqs::bigint[], @roles::text[], @blocks::jsonb[]) as t(s, r, b). Cách này làm các lần thêm đồng thời an toàn mà không cần advisory lock. Thêm một test đồng thời chạy 20 lần append song song và khẳng định 20 giá trịseqduy nhất liên tiếp. - Cài
MessageStore.History: select các block theo thứ tự và dựng lại[]provider.Messagecho vòng lặp; khẳng định trong một test rằng một thinking block sống sót qua round trip cùng signature của nó. - Viết
internal/store/sessionkey.govà unit test của nó, gồm việc từ chối các key chứa:trong external id. - Viết lớp DTO và các handler. Validation: mẫu slug của agent, model không rỗng, tools là một mảng các tên có định dạng hợp lệ (registry chưa tồn tại cho tới Phase 5, nên ở đây chỉ kiểm tra hình dạng). Mọi handler lấy store từ
store.For(c.Request().Context()). - Gọi `SetAuditAction` trong mọi handler thay đổi dữ liệu của giai đoạn này: `agent.create`, `agent.update`, `agent.delete`, `session.create`, `session.delete`. Thêm một message không khai báo action nào của riêng nó: các dòng `messages` chỉ-thêm vốn đã là bản ghi, và nhân đôi chúng trong `audit_events` sẽ nhân đôi khối lượng ghi của đường nóng nhất mà không thêm thông tin mới.
- Thêm ba case mới vào
tenancy_test.go, mỗi case có cả khẳng định cross-tenant lẫn khẳng định cross-user trong cùng tenant, và thêm các dòng route vàoauthz_test.go. - Cập nhật
docs/system-architecture.mdvới phần mô hình dữ liệu và viết learning doc.
Dàn ý tài liệu học
docs/learning/04-sessions-and-messages.md phải có:
- Khái niệm: trạng thái hội thoại như một log chỉ-thêm, và vì sao việc định địa chỉ theo từng message quan trọng trước khi compaction và subagent tồn tại.
- Các lựa chọn thiết kế: một dòng cho mỗi message, block
jsonb, tính duy nhất(session_id, seq), giữ chỗlast_seqdưới row lock, quy ước session key, agent là dữ liệu (không phải code). - Đánh đổi: các dòng chỉ-thêm so với một blob
jsonbcho mỗi session (lựa chọn của goclaw) — khuếch đại ghi, delta theo từng lượt, checkpoint phình ra (DeltaChannelcủa deepagents cần có để cắt mức tăng O(N^2)), và vì sao chỉ-thêm thắng với một server. - So sánh với goclaw:
SessionDatalà một blob[]providers.Messagecộng tóm tắt và bộ đếm token; session key có cấu trúcagent:{agentKey}:{channel}:{direct|group}:{id}và vì sao nó dùng agentKey chứ không phải UUID. - Cách debug tầng này: để trả lời “vì sao session này trông sai”, lọc Loki theo
hango.session.idđể lấy toàn bộ lịch sử append, rồi mở các spanstore.messages.appendtrong Tempo để xem thứ tự cấp phát sequence và mọi tranh chấp; một lần tải history chậm hiện ra như một span pgx dài dưới request span. - So sánh với deepagents: message nằm trong state của LangGraph với một checkpointer;
thread_idlà session id;DeltaChanneltồn tại chính xác vì việc lưu lại toàn bộ transcript là O(N^2). - Vì sao hango đi khác: các dòng bền vững là điều kiện tiên quyết cho run có thể resume và HITL.
- Vì sao predicate theo người tạo thuộc về helper `visibility(scope)` của store chứ không phải handler.
- Bài tập: thêm một message, xem
jsonbtrong psql, cố tình làm hỏng việc cấp phátseqvà xem vi phạm unique. - Ngân sách thời gian: 10h triển khai + 2h viết learning doc.
Tiêu chí thành công
-
go build ./... && go vet ./... && go test ./...pass. -
go test ./internal/store/pg/ -run TestCrossTenant -vnay cho thấy các case choagents,sessions,messages. - Một
membernhận 404 cho agent, session và danh sách message của user khác, và 404 (không phải 403) khi xóa;ownervàadminnhận 200 cho cùng các tài nguyên (test). -
visibility(scope)với role rỗng hoặc không rõ trả về lỗi và method của store không trả về dòng nào (unit test). - Test append song song: 20 goroutine append vào một session và tạo ra
seq1..20 không trùng lặp. - Một message chứa thinking block có signature được tải lại giống hệt từng byte qua
History. -
DELETE /v1/agents/{id}xóa agent, các session và các message của nó; một test khẳng định không còn dòng nào. - Tạo, cập nhật và xóa một agent cùng tạo và xóa một session tạo ra các dòng
audit_eventsvới các actionagent.create,agent.update,agent.delete,session.create,session.delete, mỗi dòng cótarget_idđúng (integration test). -
GET /v1/sessions/{id}/messages?after_seq=0&limit=50trả về các message có thứ tự cùng một cursor kế tiếp. - Trong Grafana, trace của một request session cho thấy
store.messages.appendvà các span pgx, vàhango.agent.id/hango.session.idcó mặt trên cả các span lẫn các dòng Loki tương ứng.
Đánh giá rủi ro
| Rủi ro | Khả năng x Tác động | Dấu hiệu | Cách xử lý đã quyết định trước |
|---|---|---|---|
Append đồng thời tạo ra seq trùng | Medium x High | Vi phạm unique dưới tải | last_seq được giữ chỗ bằng UPDATE ... RETURNING bên trong tx (row lock); được bao phủ bởi test append song song |
Round-trip jsonb làm mất thứ tự field của block hoặc bỏ field không biết | Medium x High | Provider từ chối các thinking block gửi lại | Tag JSON tường minh, omitempty chỉ trên các field tùy chọn, test round-trip cho từng kiểu block |
Allowlist tools của agent tham chiếu các tool chưa tồn tại | High x Low | Tên không biết được chấp nhận ở Phase 4 | Hiện chỉ validate hình dạng; Phase 5 thêm validation theo registry và một kiểm tra backfill không cần migration khi nạp agent |
| Xóa một agent khi có một run đang chạy (Phase 5+) | Medium x Medium | Vi phạm FK hoặc run mồ côi | ON DELETE CASCADE hiện giờ; Phase 5 thêm một guard từ chối xóa khi các run ở trạng thái `queued |
| Bảng message tăng không giới hạn | Medium x Medium | Đọc history chậm trên các session dài | Index (tenant_id, session_id, seq) hiện giờ; Phase 10 chuyển đi nơi khác và compaction thay vì xóa |
Rollback
goose down một bước xóa messages, sessions, agents. Xương sống định danh và server từ các Phase 1-2 vẫn hoạt động; nhóm route mới được đăng ký ở một nơi trong server.go.