refactor: replace persistent subagents with ephemeral spawn tool (#280)

* refactor: replace persistent subagents with ephemeral spawn tool (#subagent)

- Drop subagents table, remove all persistent subagent infrastructure
- Add 'subagent' session type with parent_session_id on bot_sessions
- Rewrite subagent tool as single 'spawn' tool with parallel execution
- Create system_subagent.md prompt, add _subagent.md include for chat
- Limit subagent tools to file, exec, web_search, web_fetch only
- Merge subagent token usage into parent chat session in reporting
- Remove frontend subagent management page, update chat UI for spawn
- Fix UTF-8 truncation in session title, fix query not passed to agent

* refactor: remove history message page
This commit is contained in:
Acbox Liu
2026-03-22 19:03:28 +08:00
committed by GitHub
parent b88ca96064
commit b3a39ad93d
56 changed files with 716 additions and 4880 deletions
+11 -25
View File
@@ -154,16 +154,17 @@ type BotHistoryMessageCompact struct {
}
type BotSession struct {
ID pgtype.UUID `json:"id"`
BotID pgtype.UUID `json:"bot_id"`
RouteID pgtype.UUID `json:"route_id"`
ChannelType pgtype.Text `json:"channel_type"`
Type string `json:"type"`
Title string `json:"title"`
Metadata []byte `json:"metadata"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
ID pgtype.UUID `json:"id"`
BotID pgtype.UUID `json:"bot_id"`
RouteID pgtype.UUID `json:"route_id"`
ChannelType pgtype.Text `json:"channel_type"`
Type string `json:"type"`
Title string `json:"title"`
Metadata []byte `json:"metadata"`
ParentSessionID pgtype.UUID `json:"parent_session_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
}
type BotStorageBinding struct {
@@ -435,21 +436,6 @@ type StorageProvider struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type Subagent struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Deleted bool `json:"deleted"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
BotID pgtype.UUID `json:"bot_id"`
Messages []byte `json:"messages"`
Metadata []byte `json:"metadata"`
Skills []byte `json:"skills"`
Usage []byte `json:"usage"`
}
type TtsModel struct {
ID pgtype.UUID `json:"id"`
ModelID string `json:"model_id"`
+63 -14
View File
@@ -13,7 +13,7 @@ import (
const createSession = `-- name: CreateSession :one
INSERT INTO bot_sessions (
bot_id, route_id, channel_type, type, title, metadata
bot_id, route_id, channel_type, type, title, metadata, parent_session_id
)
VALUES (
$1,
@@ -21,18 +21,20 @@ VALUES (
$3::text,
$4,
$5,
$6
$6,
$7::uuid
)
RETURNING id, bot_id, route_id, channel_type, type, title, metadata, created_at, updated_at, deleted_at
RETURNING id, bot_id, route_id, channel_type, type, title, metadata, parent_session_id, created_at, updated_at, deleted_at
`
type CreateSessionParams struct {
BotID pgtype.UUID `json:"bot_id"`
RouteID pgtype.UUID `json:"route_id"`
ChannelType pgtype.Text `json:"channel_type"`
Type string `json:"type"`
Title string `json:"title"`
Metadata []byte `json:"metadata"`
BotID pgtype.UUID `json:"bot_id"`
RouteID pgtype.UUID `json:"route_id"`
ChannelType pgtype.Text `json:"channel_type"`
Type string `json:"type"`
Title string `json:"title"`
Metadata []byte `json:"metadata"`
ParentSessionID pgtype.UUID `json:"parent_session_id"`
}
func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (BotSession, error) {
@@ -43,6 +45,7 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (B
arg.Type,
arg.Title,
arg.Metadata,
arg.ParentSessionID,
)
var i BotSession
err := row.Scan(
@@ -53,6 +56,7 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (B
&i.Type,
&i.Title,
&i.Metadata,
&i.ParentSessionID,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
@@ -61,7 +65,7 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (B
}
const getActiveSessionForRoute = `-- name: GetActiveSessionForRoute :one
SELECT s.id, s.bot_id, s.route_id, s.channel_type, s.type, s.title, s.metadata, s.created_at, s.updated_at, s.deleted_at
SELECT s.id, s.bot_id, s.route_id, s.channel_type, s.type, s.title, s.metadata, s.parent_session_id, s.created_at, s.updated_at, s.deleted_at
FROM bot_sessions s
JOIN bot_channel_routes r ON r.active_session_id = s.id
WHERE r.id = $1
@@ -79,6 +83,7 @@ func (q *Queries) GetActiveSessionForRoute(ctx context.Context, routeID pgtype.U
&i.Type,
&i.Title,
&i.Metadata,
&i.ParentSessionID,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
@@ -87,7 +92,7 @@ func (q *Queries) GetActiveSessionForRoute(ctx context.Context, routeID pgtype.U
}
const getSessionByID = `-- name: GetSessionByID :one
SELECT id, bot_id, route_id, channel_type, type, title, metadata, created_at, updated_at, deleted_at
SELECT id, bot_id, route_id, channel_type, type, title, metadata, parent_session_id, created_at, updated_at, deleted_at
FROM bot_sessions
WHERE id = $1
AND deleted_at IS NULL
@@ -104,6 +109,7 @@ func (q *Queries) GetSessionByID(ctx context.Context, id pgtype.UUID) (BotSessio
&i.Type,
&i.Title,
&i.Metadata,
&i.ParentSessionID,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
@@ -173,7 +179,7 @@ func (q *Queries) ListSessionsByBot(ctx context.Context, botID pgtype.UUID) ([]L
}
const listSessionsByRoute = `-- name: ListSessionsByRoute :many
SELECT id, bot_id, route_id, channel_type, type, title, metadata, created_at, updated_at, deleted_at
SELECT id, bot_id, route_id, channel_type, type, title, metadata, parent_session_id, created_at, updated_at, deleted_at
FROM bot_sessions
WHERE route_id = $1
AND deleted_at IS NULL
@@ -197,6 +203,47 @@ func (q *Queries) ListSessionsByRoute(ctx context.Context, routeID pgtype.UUID)
&i.Type,
&i.Title,
&i.Metadata,
&i.ParentSessionID,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listSubagentSessionsByParent = `-- name: ListSubagentSessionsByParent :many
SELECT id, bot_id, route_id, channel_type, type, title, metadata, parent_session_id, created_at, updated_at, deleted_at
FROM bot_sessions
WHERE parent_session_id = $1
AND deleted_at IS NULL
ORDER BY created_at DESC
`
func (q *Queries) ListSubagentSessionsByParent(ctx context.Context, parentSessionID pgtype.UUID) ([]BotSession, error) {
rows, err := q.db.Query(ctx, listSubagentSessionsByParent, parentSessionID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []BotSession
for rows.Next() {
var i BotSession
if err := rows.Scan(
&i.ID,
&i.BotID,
&i.RouteID,
&i.ChannelType,
&i.Type,
&i.Title,
&i.Metadata,
&i.ParentSessionID,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
@@ -248,7 +295,7 @@ const updateSessionMetadata = `-- name: UpdateSessionMetadata :one
UPDATE bot_sessions
SET metadata = $1, updated_at = now()
WHERE id = $2 AND deleted_at IS NULL
RETURNING id, bot_id, route_id, channel_type, type, title, metadata, created_at, updated_at, deleted_at
RETURNING id, bot_id, route_id, channel_type, type, title, metadata, parent_session_id, created_at, updated_at, deleted_at
`
type UpdateSessionMetadataParams struct {
@@ -267,6 +314,7 @@ func (q *Queries) UpdateSessionMetadata(ctx context.Context, arg UpdateSessionMe
&i.Type,
&i.Title,
&i.Metadata,
&i.ParentSessionID,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
@@ -278,7 +326,7 @@ const updateSessionTitle = `-- name: UpdateSessionTitle :one
UPDATE bot_sessions
SET title = $1, updated_at = now()
WHERE id = $2 AND deleted_at IS NULL
RETURNING id, bot_id, route_id, channel_type, type, title, metadata, created_at, updated_at, deleted_at
RETURNING id, bot_id, route_id, channel_type, type, title, metadata, parent_session_id, created_at, updated_at, deleted_at
`
type UpdateSessionTitleParams struct {
@@ -297,6 +345,7 @@ func (q *Queries) UpdateSessionTitle(ctx context.Context, arg UpdateSessionTitle
&i.Type,
&i.Title,
&i.Metadata,
&i.ParentSessionID,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
-307
View File
@@ -1,307 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: subagents.sql
package sqlc
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createSubagent = `-- name: CreateSubagent :one
INSERT INTO subagents (name, description, bot_id, messages, metadata, skills)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, name, description, created_at, updated_at, deleted, deleted_at, bot_id, messages, metadata, skills, usage
`
type CreateSubagentParams struct {
Name string `json:"name"`
Description string `json:"description"`
BotID pgtype.UUID `json:"bot_id"`
Messages []byte `json:"messages"`
Metadata []byte `json:"metadata"`
Skills []byte `json:"skills"`
}
func (q *Queries) CreateSubagent(ctx context.Context, arg CreateSubagentParams) (Subagent, error) {
row := q.db.QueryRow(ctx, createSubagent,
arg.Name,
arg.Description,
arg.BotID,
arg.Messages,
arg.Metadata,
arg.Skills,
)
var i Subagent
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.CreatedAt,
&i.UpdatedAt,
&i.Deleted,
&i.DeletedAt,
&i.BotID,
&i.Messages,
&i.Metadata,
&i.Skills,
&i.Usage,
)
return i, err
}
const getSubagentByBotAndName = `-- name: GetSubagentByBotAndName :one
SELECT id, name, description, created_at, updated_at, deleted, deleted_at, bot_id, messages, metadata, skills, usage
FROM subagents
WHERE bot_id = $1 AND name = $2 AND deleted = false
`
type GetSubagentByBotAndNameParams struct {
BotID pgtype.UUID `json:"bot_id"`
Name string `json:"name"`
}
func (q *Queries) GetSubagentByBotAndName(ctx context.Context, arg GetSubagentByBotAndNameParams) (Subagent, error) {
row := q.db.QueryRow(ctx, getSubagentByBotAndName, arg.BotID, arg.Name)
var i Subagent
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.CreatedAt,
&i.UpdatedAt,
&i.Deleted,
&i.DeletedAt,
&i.BotID,
&i.Messages,
&i.Metadata,
&i.Skills,
&i.Usage,
)
return i, err
}
const getSubagentByID = `-- name: GetSubagentByID :one
SELECT id, name, description, created_at, updated_at, deleted, deleted_at, bot_id, messages, metadata, skills, usage
FROM subagents
WHERE id = $1 AND deleted = false
`
func (q *Queries) GetSubagentByID(ctx context.Context, id pgtype.UUID) (Subagent, error) {
row := q.db.QueryRow(ctx, getSubagentByID, id)
var i Subagent
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.CreatedAt,
&i.UpdatedAt,
&i.Deleted,
&i.DeletedAt,
&i.BotID,
&i.Messages,
&i.Metadata,
&i.Skills,
&i.Usage,
)
return i, err
}
const listSubagentsByBot = `-- name: ListSubagentsByBot :many
SELECT id, name, description, created_at, updated_at, deleted, deleted_at, bot_id, messages, metadata, skills, usage
FROM subagents
WHERE bot_id = $1 AND deleted = false
ORDER BY created_at DESC
`
func (q *Queries) ListSubagentsByBot(ctx context.Context, botID pgtype.UUID) ([]Subagent, error) {
rows, err := q.db.Query(ctx, listSubagentsByBot, botID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Subagent
for rows.Next() {
var i Subagent
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.CreatedAt,
&i.UpdatedAt,
&i.Deleted,
&i.DeletedAt,
&i.BotID,
&i.Messages,
&i.Metadata,
&i.Skills,
&i.Usage,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const softDeleteSubagent = `-- name: SoftDeleteSubagent :exec
UPDATE subagents
SET deleted = true,
deleted_at = now(),
updated_at = now()
WHERE id = $1 AND deleted = false
`
func (q *Queries) SoftDeleteSubagent(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, softDeleteSubagent, id)
return err
}
const updateSubagent = `-- name: UpdateSubagent :one
UPDATE subagents
SET name = $2,
description = $3,
metadata = $4,
updated_at = now()
WHERE id = $1 AND deleted = false
RETURNING id, name, description, created_at, updated_at, deleted, deleted_at, bot_id, messages, metadata, skills, usage
`
type UpdateSubagentParams struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Metadata []byte `json:"metadata"`
}
func (q *Queries) UpdateSubagent(ctx context.Context, arg UpdateSubagentParams) (Subagent, error) {
row := q.db.QueryRow(ctx, updateSubagent,
arg.ID,
arg.Name,
arg.Description,
arg.Metadata,
)
var i Subagent
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.CreatedAt,
&i.UpdatedAt,
&i.Deleted,
&i.DeletedAt,
&i.BotID,
&i.Messages,
&i.Metadata,
&i.Skills,
&i.Usage,
)
return i, err
}
const updateSubagentMessages = `-- name: UpdateSubagentMessages :one
UPDATE subagents
SET messages = $2,
updated_at = now()
WHERE id = $1 AND deleted = false
RETURNING id, name, description, created_at, updated_at, deleted, deleted_at, bot_id, messages, metadata, skills, usage
`
type UpdateSubagentMessagesParams struct {
ID pgtype.UUID `json:"id"`
Messages []byte `json:"messages"`
}
func (q *Queries) UpdateSubagentMessages(ctx context.Context, arg UpdateSubagentMessagesParams) (Subagent, error) {
row := q.db.QueryRow(ctx, updateSubagentMessages, arg.ID, arg.Messages)
var i Subagent
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.CreatedAt,
&i.UpdatedAt,
&i.Deleted,
&i.DeletedAt,
&i.BotID,
&i.Messages,
&i.Metadata,
&i.Skills,
&i.Usage,
)
return i, err
}
const updateSubagentMessagesAndUsage = `-- name: UpdateSubagentMessagesAndUsage :one
UPDATE subagents
SET messages = $2,
usage = $3,
updated_at = now()
WHERE id = $1 AND deleted = false
RETURNING id, name, description, created_at, updated_at, deleted, deleted_at, bot_id, messages, metadata, skills, usage
`
type UpdateSubagentMessagesAndUsageParams struct {
ID pgtype.UUID `json:"id"`
Messages []byte `json:"messages"`
Usage []byte `json:"usage"`
}
func (q *Queries) UpdateSubagentMessagesAndUsage(ctx context.Context, arg UpdateSubagentMessagesAndUsageParams) (Subagent, error) {
row := q.db.QueryRow(ctx, updateSubagentMessagesAndUsage, arg.ID, arg.Messages, arg.Usage)
var i Subagent
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.CreatedAt,
&i.UpdatedAt,
&i.Deleted,
&i.DeletedAt,
&i.BotID,
&i.Messages,
&i.Metadata,
&i.Skills,
&i.Usage,
)
return i, err
}
const updateSubagentSkills = `-- name: UpdateSubagentSkills :one
UPDATE subagents
SET skills = $2,
updated_at = now()
WHERE id = $1 AND deleted = false
RETURNING id, name, description, created_at, updated_at, deleted, deleted_at, bot_id, messages, metadata, skills, usage
`
type UpdateSubagentSkillsParams struct {
ID pgtype.UUID `json:"id"`
Skills []byte `json:"skills"`
}
func (q *Queries) UpdateSubagentSkills(ctx context.Context, arg UpdateSubagentSkillsParams) (Subagent, error) {
row := q.db.QueryRow(ctx, updateSubagentSkills, arg.ID, arg.Skills)
var i Subagent
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.CreatedAt,
&i.UpdatedAt,
&i.Deleted,
&i.DeletedAt,
&i.BotID,
&i.Messages,
&i.Metadata,
&i.Skills,
&i.Usage,
)
return i, err
}
+5 -1
View File
@@ -13,7 +13,10 @@ import (
const getTokenUsageByDayAndType = `-- name: GetTokenUsageByDayAndType :many
SELECT
COALESCE(s.type, 'chat')::text AS session_type,
COALESCE(
CASE WHEN s.type = 'subagent' THEN COALESCE(ps.type, 'chat') ELSE s.type END,
'chat'
)::text AS session_type,
date_trunc('day', m.created_at)::date AS day,
COALESCE(SUM((m.usage->>'inputTokens')::bigint), 0)::bigint AS input_tokens,
COALESCE(SUM((m.usage->>'outputTokens')::bigint), 0)::bigint AS output_tokens,
@@ -22,6 +25,7 @@ SELECT
COALESCE(SUM((m.usage->'outputTokenDetails'->>'reasoningTokens')::bigint), 0)::bigint AS reasoning_tokens
FROM bot_history_messages m
LEFT JOIN bot_sessions s ON s.id = m.session_id
LEFT JOIN bot_sessions ps ON ps.id = s.parent_session_id
WHERE m.bot_id = $1
AND m.usage IS NOT NULL
AND m.created_at >= $2