Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,15 @@ agents_dir: agents
# 系统会从该目录加载所有 .yaml 格式的角色配置文件
# 每个角色应创建独立的配置文件,例如:roles/CTF.yaml, roles/默认.yaml 等
roles_dir: roles # 角色配置文件目录(相对于配置文件所在目录)
# ============================================
# 任务经验草稿(实验性,仅草稿)
# ============================================
learning:
enabled: false # 实验性:任务结束后后台提炼经验草稿,不自动启用 Skill;修改后重启
max_jobs_per_day: 20 # UTC 自然日作业上限(重试最多 3 次),后台单并发
timeout_seconds: 60 # 单次模型提炼超时,最大 120 秒
max_input_runes: 12000 # 证据正文总字符预算,最大 32000;每条最多 2000 字符

# ============================================
# 项目管理与事实黑板
# ============================================
Expand Down
16 changes: 16 additions & 0 deletions docs/zh-CN/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,22 @@ multi_agent:

`agents_dir` 指向 Markdown 子代理目录。单个代理可在 front matter 中设置 `tools`、`bind_role`、`max_iterations`。

## 任务经验草稿

```yaml
learning:
enabled: false
max_jobs_per_day: 20
timeout_seconds: 60
max_input_runes: 12000
```

- `enabled`:实验性功能,默认关闭。开启后任务结束时在后台提炼经验草稿,只落草稿,不自动启用任何 Skill;修改后需重启。
- `max_jobs_per_day`:UTC 自然日内的草稿作业上限,默认 20,上限 200。
- `timeout_seconds`:单次模型提炼超时,默认 60 秒,上限 120 秒。
- `max_input_runes`:证据正文总字符预算,默认 12000,上限 32000,单条最多 2000 字符。
- 三项数值省略或填 0 时取默认值,超过上限按上限截断。后台单并发执行,同一作业失败最多重试 3 次后放弃。

## 工具与 MCP

```yaml
Expand Down
1 change: 1 addition & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -1027,6 +1027,7 @@ func setupRoutes(
// Eino ADK 单代理(ChatModelAgent + Runner;不依赖 multi_agent.enabled)
protected.POST("/eino-agent", agentHandler.EinoSingleAgentLoop)
protected.POST("/eino-agent/stream", agentHandler.EinoSingleAgentLoopStream)
protected.GET("/conversations/:id/learning", agentHandler.ListConversationLearning)
protected.GET("/hitl/pending", agentHandler.ListHITLPending)
protected.GET("/hitl/logs", agentHandler.ListHITLLogs)
protected.DELETE("/hitl/logs", agentHandler.DeleteHITLLogs)
Expand Down
1 change: 1 addition & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ type Config struct {
Project ProjectConfig `yaml:"project,omitempty" json:"project,omitempty"`
Vision VisionConfig `yaml:"vision,omitempty" json:"vision,omitempty"`
Update UpdateConfig `yaml:"update,omitempty" json:"update,omitempty"`
Learning LearningConfig `yaml:"learning,omitempty" json:"learning,omitempty"`
}

// UpdateConfig 一键更新的更新源(可选):一个仓库地址,更新跟随该仓库的默认分支。
Expand Down
23 changes: 23 additions & 0 deletions internal/config/learning.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package config

// LearningConfig enables the experimental, draft-only post-task learner.
// Settings are captured at startup; changing them requires a restart.
type LearningConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
MaxJobsPerDay int `yaml:"max_jobs_per_day,omitempty" json:"max_jobs_per_day,omitempty"`
TimeoutSeconds int `yaml:"timeout_seconds,omitempty" json:"timeout_seconds,omitempty"`
MaxInputRunes int `yaml:"max_input_runes,omitempty" json:"max_input_runes,omitempty"`
}

func (c LearningConfig) DailyLimit() int { return boundedLearningValue(c.MaxJobsPerDay, 20, 200) }
func (c LearningConfig) Timeout() int { return boundedLearningValue(c.TimeoutSeconds, 60, 120) }
func (c LearningConfig) InputLimit() int { return boundedLearningValue(c.MaxInputRunes, 12000, 32000) }
func boundedLearningValue(n, fallback, maximum int) int {
if n <= 0 {
return fallback
}
if n > maximum {
return maximum
}
return n
}
23 changes: 23 additions & 0 deletions internal/config/learning_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package config

import (
"gopkg.in/yaml.v3"
"testing"
)

func TestLearningOptInAndHardLimits(t *testing.T) {
var cfg Config
if err := yaml.Unmarshal([]byte("learning:\n enabled: true\n max_jobs_per_day: 999\n timeout_seconds: 999\n max_input_runes: 999999\n"), &cfg); err != nil {
t.Fatal(err)
}
if !cfg.Learning.Enabled || cfg.Learning.DailyLimit() != 200 || cfg.Learning.Timeout() != 120 || cfg.Learning.InputLimit() != 32000 {
t.Fatalf("invalid bounds: %+v", cfg.Learning)
}
var defaultCfg Config
if err := yaml.Unmarshal([]byte("version: test\n"), &defaultCfg); err != nil {
t.Fatal(err)
}
if defaultCfg.Learning.Enabled || defaultCfg.Learning.DailyLimit() != 20 || defaultCfg.Learning.Timeout() != 60 || defaultCfg.Learning.InputLimit() != 12000 {
t.Fatal("learning not opt-in or defaults changed")
}
}
279 changes: 279 additions & 0 deletions internal/database/learning.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
package database

import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"

"github.com/google/uuid"
)

const LearningExtractorVersion = "draft-v1"

type LearningJob struct {
ID string `json:"id"`
ConversationID string `json:"conversationId"`
ProjectID string `json:"projectId,omitempty"`
OwnerUserID string `json:"-"`
AnchorMessageID string `json:"anchorMessageId"`
RunStatus string `json:"runStatus"`
Outcome string `json:"outcome"`
Status string `json:"status"`
Attempts int `json:"attempts"`
CandidateJSON string `json:"candidateJson,omitempty"`
Reason string `json:"reason,omitempty"`
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
LeaseToken string `json:"-"`
StartRowID int64 `json:"-"`
EndRowID int64 `json:"-"`
}

type LearningEvidence struct {
ID string `json:"id"`
Kind string `json:"kind"`
Text string `json:"text"`
Truncated bool `json:"truncated,omitempty"`
}

// BeginLearningRun captures identity and the user-message anchor before the run
// starts. No transcript copies or files are created.
func (db *DB) BeginLearningRun(runID, conversationID string) error {
now := time.Now().Unix()
_, err := db.Exec(`INSERT OR IGNORE INTO learning_jobs
(id,extractor_version,conversation_id,anchor_message_id,project_id,owner_user_id,start_rowid,created_at,updated_at)
SELECT ?,?,c.id,m.id,c.project_id,c.owner_user_id,m.rowid,?,?
FROM conversations c JOIN messages m ON m.conversation_id=c.id
WHERE c.id=? AND m.role='user' AND COALESCE(c.owner_user_id,'')<>''
AND (SELECT COUNT(*) FROM learning_jobs WHERE status IN ('waiting','queued','processing'))<200
AND m.rowid=(SELECT MAX(rowid) FROM messages WHERE conversation_id=c.id AND role='user')`,
runID, LearningExtractorVersion, now, now, conversationID)
return err
}

// FinishLearningRun is idempotent. Unknown business outcomes never become
// successful just because the execution completed.
func (db *DB) FinishLearningRun(runID, status string) error {
state, reason := "skipped", "运行未正常收尾,不提炼技能"
if status == "completed" || status == "failed" {
state, reason = "queued", ""
}
_, err := db.Exec(`UPDATE learning_jobs SET run_status=?, status=?, reason=?,
end_rowid=COALESCE((SELECT MIN(rowid)-1 FROM messages WHERE conversation_id=learning_jobs.conversation_id AND role='user' AND rowid>start_rowid),
(SELECT MAX(rowid) FROM messages WHERE conversation_id=learning_jobs.conversation_id),start_rowid),updated_at=?
WHERE id=? AND status='waiting'`, status, state, reason, time.Now().Unix(), runID)
return err
}

// RecoverLearningRuns is called before accepting requests on process startup.
// A crash cannot be interpreted as a completed task. Expired worker leases are
// recovered by ClaimLearningJob without inventing a task result.
func (db *DB) RecoverLearningRuns() error {
_, err := db.Exec(`UPDATE learning_jobs SET status='skipped',run_status='interrupted',reason='服务中断,缺少可靠运行终态',updated_at=? WHERE status='waiting'`, time.Now().Unix())
return err
}

const learningJobColumns = `id,conversation_id,COALESCE(project_id,''),owner_user_id,anchor_message_id,run_status,outcome,status,attempts,candidate_json,reason,prompt_tokens,completion_tokens,created_at,updated_at,lease_token,start_rowid,end_rowid`

type learningScanner interface{ Scan(...any) error }

func scanLearningJob(row learningScanner) (*LearningJob, error) {
j := new(LearningJob)
err := row.Scan(&j.ID, &j.ConversationID, &j.ProjectID, &j.OwnerUserID, &j.AnchorMessageID, &j.RunStatus, &j.Outcome, &j.Status, &j.Attempts, &j.CandidateJSON, &j.Reason, &j.PromptTokens, &j.CompletionTokens, &j.CreatedAt, &j.UpdatedAt, &j.LeaseToken, &j.StartRowID, &j.EndRowID)
return j, err
}

// ClaimLearningJob uses one atomic UPDATE, so competing workers cannot share a
// lease. Daily limits count jobs, including retries of already-started jobs.
func (db *DB) ClaimLearningJob(now time.Time, dailyLimit int, lease time.Duration) (*LearningJob, error) {
if dailyLimit < 1 || lease <= 0 {
return nil, fmt.Errorf("invalid learning queue limits")
}
if _, err := db.Exec(`UPDATE learning_jobs SET status='failed',lease_token='',lease_until=0,reason='提炼重试已耗尽',updated_at=? WHERE status='processing' AND lease_until<=? AND attempts>=3`, now.Unix(), now.Unix()); err != nil {
return nil, err
}
day := now.UTC().Format("2006-01-02")
j, err := scanLearningJob(db.QueryRow(`UPDATE learning_jobs SET status='processing',attempts=attempts+1,
lease_token=?,lease_until=?,started_day=?,updated_at=?
WHERE id=(SELECT id FROM learning_jobs WHERE extractor_version=? AND attempts<3
AND ((status='queued' AND next_attempt_at<=?) OR (status='processing' AND lease_until<=?))
AND (started_day=? OR (SELECT COUNT(*) FROM learning_jobs WHERE started_day=?)<?)
ORDER BY created_at,id LIMIT 1) RETURNING `+learningJobColumns,
uuid.NewString(), now.Add(lease).Unix(), day, now.Unix(), LearningExtractorVersion, now.Unix(), now.Unix(), day, day, dailyLimit))
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return j, err
}

// LearningSourceValid rechecks both the original scope and current permissions.
func (db *DB) LearningSourceValid(j *LearningJob) bool {
var n int
if err := db.QueryRow(`SELECT COUNT(*) FROM learning_jobs j JOIN conversations c ON c.id=j.conversation_id
JOIN messages m ON m.id=j.anchor_message_id WHERE j.id=? AND j.lease_token=? AND j.status='processing'
AND c.owner_user_id=j.owner_user_id AND COALESCE(c.project_id,'')=COALESCE(j.project_id,'')`, j.ID, j.LeaseToken).Scan(&n); err != nil || n != 1 {
return false
}
a, err := db.ResolveRBACAccess(j.OwnerUserID)
if err != nil || !a.User.Enabled || !a.Permissions["chat:read"] || !db.UserCanAccessResource(j.OwnerUserID, a.PermissionScopes["chat:read"], "conversation", j.ConversationID) {
return false
}
return j.ProjectID == "" || (a.Permissions["project:read"] && db.UserCanAccessResource(j.OwnerUserID, a.PermissionScopes["project:read"], "project", j.ProjectID))
}

// LearningRunEvidence reads a bounded slice of source messages/tool results.
// The message rowid boundary prevents a later run leaking into a delayed job.
func (db *DB) LearningRunEvidence(j *LearningJob, maxRunes int) ([]LearningEvidence, error) {
if maxRunes < 1 || maxRunes > 32000 {
return nil, fmt.Errorf("invalid evidence budget")
}
perItem := maxRunes / 4
if perItem > 2000 {
perItem = 2000
}
if perItem < 1 {
perItem = 1
}
rows, err := db.Query(`SELECT id,kind,text FROM (
SELECT m.id,m.role AS kind,substr(m.content,1,?) AS text,m.rowid AS seq,0 AS sub
FROM messages m WHERE m.conversation_id=? AND m.rowid BETWEEN ? AND ?
UNION ALL
SELECT p.id,p.event_type,substr(COALESCE(p.data,p.message,''),1,?),m.rowid,p.rowid
FROM process_details p JOIN messages m ON m.id=p.message_id
WHERE m.conversation_id=? AND m.rowid BETWEEN ? AND ? AND p.event_type IN ('tool_result','error')
) ORDER BY seq,sub LIMIT 100`, perItem+1, j.ConversationID, j.StartRowID, j.EndRowID, perItem+1, j.ConversationID, j.StartRowID, j.EndRowID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []LearningEvidence{}
for rows.Next() {
var e LearningEvidence
if err := rows.Scan(&e.ID, &e.Kind, &e.Text); err != nil {
return nil, err
}
r := []rune(e.Text)
if len(r) > perItem {
r = r[:perItem]
e.Truncated = true
}
if len(r) > maxRunes {
r = r[:maxRunes]
e.Truncated = true
}
e.Text = string(r)
maxRunes -= len(r)
out = append(out, e)
if maxRunes <= 0 {
break
}
}
return out, rows.Err()
}

func (db *DB) CompleteLearningJob(j *LearningJob, status, candidate, hash, reason string, promptTokens, completionTokens int) error {
if status != "drafted" && status != "skipped" {
return fmt.Errorf("invalid learning result")
}
if !db.LearningSourceValid(j) {
status, candidate, hash, reason = "skipped", "", "", "来源已删除、范围变更或权限失效"
}
if status == "drafted" && !db.learningCandidateEvidenceValid(j, candidate) {
status, candidate, hash, reason = "skipped", "", "", "来源证据已删除或无效"
}
// Exact duplicate suppression is deliberately conversation-scoped; no
// implicit cross-project sharing or deletion dependencies are introduced.
if hash != "" {
if err := db.InvalidateLearningDrafts(j.ConversationID); err != nil {
return err
}
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM learning_jobs WHERE conversation_id=? AND id<>? AND content_hash=? AND status='drafted'`, j.ConversationID, j.ID, hash).Scan(&count); err != nil {
return err
}
if count > 0 {
status, candidate, hash, reason = "skipped", "", "", "相同会话已有相同技能草稿"
}
}
_, err := db.Exec(`UPDATE learning_jobs SET status=?,candidate_json=?,content_hash=?,reason=?,prompt_tokens=prompt_tokens+?,completion_tokens=completion_tokens+?,lease_token='',lease_until=0,updated_at=?
WHERE id=? AND lease_token=? AND status='processing'
AND (?='skipped' OR EXISTS(SELECT 1 FROM conversations c WHERE c.id=learning_jobs.conversation_id AND c.owner_user_id=learning_jobs.owner_user_id AND COALESCE(c.project_id,'')=COALESCE(learning_jobs.project_id,'')))`,
status, candidate, hash, reason, promptTokens, completionTokens, time.Now().Unix(), j.ID, j.LeaseToken, status)
return err
}

func (db *DB) RetryLearningJob(j *LearningJob, reason string, now time.Time) error {
status := "queued"
if j.Attempts >= 3 {
status = "failed"
}
_, err := db.Exec(`UPDATE learning_jobs SET status=?,reason=?,next_attempt_at=?,lease_token='',lease_until=0,updated_at=? WHERE id=? AND lease_token=? AND status='processing'`,
status, reason, now.Add(time.Minute).Unix(), now.Unix(), j.ID, j.LeaseToken)
return err
}

func (db *DB) ListLearningJobs(conversationID string) ([]LearningJob, error) {
if err := db.InvalidateLearningDrafts(conversationID); err != nil {
return nil, err
}
rows, err := db.Query(`SELECT `+learningJobColumns+` FROM learning_jobs j WHERE conversation_id=?
AND EXISTS(SELECT 1 FROM conversations c WHERE c.id=j.conversation_id AND c.owner_user_id=j.owner_user_id AND COALESCE(c.project_id,'')=COALESCE(j.project_id,''))
ORDER BY created_at DESC,id LIMIT 50`, strings.TrimSpace(conversationID))
if err != nil {
return nil, err
}
defer rows.Close()
jobs := []LearningJob{}
for rows.Next() {
j, err := scanLearningJob(rows)
if err != nil {
return nil, err
}
jobs = append(jobs, *j)
}
return jobs, rows.Err()
}

func (db *DB) learningCandidateEvidenceValid(j *LearningJob, candidate string) bool {
var refs struct {
IDs []string `json:"evidenceIds"`
}
if json.Unmarshal([]byte(candidate), &refs) != nil || len(refs.IDs) == 0 {
return false
}
var n int
err := db.QueryRow(`SELECT COUNT(*) FROM json_each(?,'$.evidenceIds') e WHERE
NOT EXISTS(SELECT 1 FROM messages m WHERE m.id=e.value AND m.conversation_id=? AND m.rowid BETWEEN ? AND ?)
AND NOT EXISTS(SELECT 1 FROM process_details p JOIN messages m ON m.id=p.message_id WHERE p.id=e.value AND m.conversation_id=? AND m.rowid BETWEEN ? AND ?)`,
candidate, j.ConversationID, j.StartRowID, j.EndRowID, j.ConversationID, j.StartRowID, j.EndRowID).Scan(&n)
return err == nil && n == 0
}

// InvalidateLearningDrafts also handles removal of individual evidence records.
func (db *DB) InvalidateLearningDrafts(conversationID string) error {
_, err := db.Exec(`UPDATE learning_jobs SET status='skipped',candidate_json='',content_hash='',reason='来源证据已删除或范围变更',updated_at=?
WHERE conversation_id=? AND status='drafted' AND (
NOT EXISTS(SELECT 1 FROM conversations c WHERE c.id=learning_jobs.conversation_id AND c.owner_user_id=learning_jobs.owner_user_id AND COALESCE(c.project_id,'')=COALESCE(learning_jobs.project_id,''))
OR EXISTS(SELECT 1 FROM json_each(learning_jobs.candidate_json,'$.evidenceIds') e WHERE
NOT EXISTS(SELECT 1 FROM messages m WHERE m.id=e.value AND m.conversation_id=learning_jobs.conversation_id AND m.rowid BETWEEN start_rowid AND end_rowid)
AND NOT EXISTS(SELECT 1 FROM process_details p JOIN messages m ON m.id=p.message_id WHERE p.id=e.value AND m.conversation_id=learning_jobs.conversation_id AND m.rowid BETWEEN start_rowid AND end_rowid)))`, time.Now().Unix(), conversationID)
return err
}

func (db *DB) RecordLearningUsage(j *LearningJob, prompt, completion int) error {
if prompt < 0 || completion < 0 {
return fmt.Errorf("invalid usage")
}
_, err := db.Exec(`UPDATE learning_jobs SET prompt_tokens=prompt_tokens+?,completion_tokens=completion_tokens+? WHERE id=? AND lease_token=? AND status='processing'`, prompt, completion, j.ID, j.LeaseToken)
return err
}

func (db *DB) PruneLearningJobs(now time.Time) error {
_, err := db.Exec(`DELETE FROM learning_jobs WHERE created_at<? AND (status<>'processing' OR lease_until<=?)`, now.Add(-30*24*time.Hour).Unix(), now.Unix())
return err
}
Loading
Loading