Chuyển đến nội dung

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ường name, slug, model, provider, system_prompt, tools jsonb (allowlist), config jsonb, created_by.
  • sessions: thuộc về một agent, mang một key dễ đọc cho người, một title, một status, created_by, và các bộ đếm dùng sau này cho compaction. created_by tồ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 theo seq), 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 seq mộ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`.
Routeowner / adminmember
POST /v1/agentsallowallow (trở thành created_by)
GET /v1/agents, GET /v1/agents/{id}mọi agent trong tenantchỉ agent của mình
PATCH/DELETE /v1/agents/{id}mọi agent trong tenantchỉ agent của mình
POST /v1/sessionsmọi agent trong tenantchỉ agent của mình; session nhận created_by
GET /v1/sessions, GET /v1/sessions/{id}mọi session trong tenantchỉ session của mình
DELETE /v1/sessions/{id}mọi session trong tenantchỉ session của mình
GET/POST /v1/sessions/{id}/messagesmọi session trong tenantchỉ 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ột member không thể đọc hay xóa agent hoặc session của user khác; owner/admin thì có thể).
  • provider.Block được lưu và tải lại ổn định từng byte qua jsonb, 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.Append và InTx thêm các span INTERNAL tường minh (store.messages.append, store.tx) với hango.session.id, hango.message.count và, với InTx, 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.id và hango.session.id lê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 (attribute role), gauge hango.sessions.active, histogram hango.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.go mà make generate ghi 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ư trong user.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 case agents, 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

  1. Viết migrations/00002_agents_sessions_messages.sql với DDL ở trên và các lệnh drop -- +goose Down tương ứng theo thứ tự ngược.
  2. Mở rộng internal/store/types.go với Agent, Session, Message. Message.Blocks là []provider.Block — package store được phép import provider (một chiều; provider không bao giờ import store).
  3. Cài InTx trong internal/store/pg/tx.go: bắt đầu một pgx.Tx, dựng một scoped mới gắn với cùng Scope trê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.
  4. Viết queries/agents.sql và cài CRUD cho AgentStore dưới dạng các mapper trên các method được sinh ra, theo user.go. Mọi câu lệnh lấy @tenant_id từ scope. Tools và Config là jsonb, nên chúng đến dưới dạng json.RawMessage qua override sẵn có. Chạy make generate và make sqlc-check.
  5. Cài SessionStore: Create (đặt created_by từ 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 cho ownUserID củ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 cho owner và admin, user id của người gọi cho member. visibility(scope) trả về lỗi với bất kỳ role nào ngoài owner, admin hoặc member, 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.
  6. Cài MessageStore.Append: bên trong InTx, một ReserveSeq :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ách VALUES có độ 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ị seq duy nhất liên tiếp.
  7. Cài MessageStore.History: select các block theo thứ tự và dựng lại []provider.Message cho 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ó.
  8. Viết internal/store/sessionkey.go và unit test của nó, gồm việc từ chối các key chứa : trong external id.
  9. 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()).
  10. 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.
  11. 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ào authz_test.go.
  12. Cập nhật docs/system-architecture.md vớ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_seq dướ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 jsonb cho mỗi session (lựa chọn của goclaw) — khuếch đại ghi, delta theo từng lượt, checkpoint phình ra (DeltaChannel củ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: SessionData là một blob []providers.Message cộng tóm tắt và bộ đếm token; session key có cấu trúc agent:{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 span store.messages.append trong 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_id là session id; DeltaChannel tồ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 jsonb trong psql, cố tình làm hỏng việc cấp phát seq và 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 -v nay cho thấy các case cho agents, sessions, messages.
  • Một member nhậ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; owner và admin nhậ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 seq 1..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_events với các action agent.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=50 trả 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.append và các span pgx, và hango.agent.id/hango.session.id có 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 roKhả năng x Tác độngDấu hiệuCách xử lý đã quyết định trước
Append đồng thời tạo ra seq trùngMedium x HighVi phạm unique dưới tảilast_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ếtMedium x HighProvider từ chối các thinking block gửi lạiTag 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ạiHigh x LowTên không biết được chấp nhận ở Phase 4Hiệ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 MediumVi phạm FK hoặc run mồ côiON 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ạnMedium x MediumĐọc history chậm trên các session dàiIndex (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.