chore: sync upstream v0.3.6 changes

This commit is contained in:
pphua committed 2026-08-28 14:03:07 +08:00
1 parent 9da521ee4e
commit 22132a4d21
34 files changed
+897 -127

No files matched your search

+57 -12
View File
@@ -19,18 +19,10 @@ type CommandRecord struct {
CreatedAt time.Time `json:"created_at"`
}
// ListCommands returns tool executions (tool_use + paired tool_result) across all
// explorations, with optional filtering and pagination. Covers every tool, not
// just Bash; q matches the tool name or its input.
func (d *DB) ListCommands(expID *int64, q string, page, size int) ([]CommandRecord, int, error) {
if size <= 0 {
size = 50
}
if page < 0 {
page = 0
}
offset := page * size
// commandFilter builds the WHERE clause shared by the tool-execution list and
// its per-tool tally, so the summary always describes exactly the rows the table
// pages through. Returns the clause, its args, and the next placeholder index.
func commandFilter(expID *int64, q string) (string, []any, int) {
where := `WHERE u.kind = 'tool_use'`
args := []any{}
argN := 1
@@ -45,6 +37,59 @@ func (d *DB) ListCommands(expID *int64, q string, page, size int) ([]CommandReco
args = append(args, "%"+q+"%")
argN++
}
return where, args, argN
}
// ToolStat is one tool's execution tally for the usage summary.
type ToolStat struct {
Tool string `json:"tool"`
Total int `json:"total"`
Errors int `json:"errors"`
}
// ToolStats counts executions grouped by tool under the same filters
// ListCommands takes. Unpaginated on purpose: the tally describes the whole
// filtered set, not the page currently on screen.
func (d *DB) ToolStats(expID *int64, q string) ([]ToolStat, error) {
where, args, _ := commandFilter(expID, q)
rows, err := d.Query(`
SELECT COALESCE(NULLIF(u.tool,''),'-') AS tool, COUNT(*) AS total,
COUNT(*) FILTER (WHERE COALESCE(r.is_error,false)) AS errors
FROM activity u
LEFT JOIN activity r ON r.tool_use_id = u.tool_use_id AND r.kind = 'tool_result'
`+where+`
GROUP BY 1
ORDER BY total DESC, tool ASC`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []ToolStat{}
for rows.Next() {
var s ToolStat
if err := rows.Scan(&s.Tool, &s.Total, &s.Errors); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// ListCommands returns tool executions (tool_use + paired tool_result) across all
// explorations, with optional filtering and pagination. Covers every tool, not
// just Bash; q matches the tool name or its input.
func (d *DB) ListCommands(expID *int64, q string, page, size int) ([]CommandRecord, int, error) {
if size <= 0 {
size = 50
}
if page < 0 {
page = 0
}
offset := page * size
where, args, argN := commandFilter(expID, q)
// count
var total int
+15 -10
View File
@@ -39,18 +39,23 @@ type LLMProfile struct {
// usable when an agent/task binds it explicitly, it just never gets picked up
// as a fallback target.
PoolExclude bool `json:"pool_exclude"`
// Streaming selects the wire protocol: true (default) = streaming (SSE);
// false = real non-streaming (stream:false, single JSON response via
// Provider.Complete). Non-streaming sidesteps flaky gateway SSE at the cost of
// live in-run progress. Maps to agent.Config.Stream.
Streaming bool `json:"streaming"`
}
// profileCols is the read column list (hint variant, no api key) shared by the
// list query; profileColsKey is the same with api_key for the single-row loads.
const profileCols = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key_hint,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude,COALESCE(thinking_type,'')`
const profileColsKey = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude,COALESCE(thinking_type,'')`
const profileCols = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key_hint,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude,COALESCE(thinking_type,''),COALESCE(streaming,true)`
const profileColsKey = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude,COALESCE(thinking_type,''),COALESCE(streaming,true)`
// scanProfile reads one row in the profileCols / profileColsKey column order. The
// 7th column lands in APIKeyHint or APIKey depending on which list the caller used.
func scanProfile(sc interface{ Scan(...any) error }, into *string, p *LLMProfile) error {
return sc.Scan(&p.ID, &p.Name, &p.Format, &p.BaseURL, &p.Proxy, &p.Model, into,
&p.RatePerSecond, &p.RatePerMinute, &p.ContextWindowK, &p.ReasoningEffort, &p.IsDefault, &p.Priority, &p.PoolExclude, &p.ThinkingType)
&p.RatePerSecond, &p.RatePerMinute, &p.ContextWindowK, &p.ReasoningEffort, &p.IsDefault, &p.Priority, &p.PoolExclude, &p.ThinkingType, &p.Streaming)
}
func (d *DB) ListProfiles() ([]*LLMProfile, error) {
@@ -123,18 +128,18 @@ func (d *DB) SaveProfile(p *LLMProfile) (int64, error) {
}
if p.ID == 0 {
var id int64
err := d.QueryRow(`INSERT INTO llm_profiles(name,format,base_url,proxy,model,api_key,api_key_hint,rate_per_second,rate_per_minute,context_window_k,reasoning_effort,priority,pool_exclude,thinking_type)
VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,NULLIF($6,''),NULLIF($7,''),$8,$9,$10,$11,$12,$13,$14) RETURNING id`,
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.APIKey, hint, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType).Scan(&id)
err := d.QueryRow(`INSERT INTO llm_profiles(name,format,base_url,proxy,model,api_key,api_key_hint,rate_per_second,rate_per_minute,context_window_k,reasoning_effort,priority,pool_exclude,thinking_type,streaming)
VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,NULLIF($6,''),NULLIF($7,''),$8,$9,$10,$11,$12,$13,$14,$15) RETURNING id`,
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.APIKey, hint, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.Streaming).Scan(&id)
return id, err
}
if p.APIKey == "" {
_, err := d.Exec(`UPDATE llm_profiles SET name=$1,format=$2,base_url=NULLIF($3,''),proxy=NULLIF($4,''),model=$5,rate_per_second=$6,rate_per_minute=$7,context_window_k=$8,reasoning_effort=$9,priority=$10,pool_exclude=$11,thinking_type=$12 WHERE id=$13`,
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.ID)
_, err := d.Exec(`UPDATE llm_profiles SET name=$1,format=$2,base_url=NULLIF($3,''),proxy=NULLIF($4,''),model=$5,rate_per_second=$6,rate_per_minute=$7,context_window_k=$8,reasoning_effort=$9,priority=$10,pool_exclude=$11,thinking_type=$12,streaming=$13 WHERE id=$14`,
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.Streaming, p.ID)
return p.ID, err
}
_, err := d.Exec(`UPDATE llm_profiles SET name=$1,format=$2,base_url=NULLIF($3,''),proxy=NULLIF($4,''),model=$5,api_key=$6,api_key_hint=$7,rate_per_second=$8,rate_per_minute=$9,context_window_k=$10,reasoning_effort=$11,priority=$12,pool_exclude=$13,thinking_type=$14 WHERE id=$15`,
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.APIKey, hint, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.ID)
_, err := d.Exec(`UPDATE llm_profiles SET name=$1,format=$2,base_url=NULLIF($3,''),proxy=NULLIF($4,''),model=$5,api_key=$6,api_key_hint=$7,rate_per_second=$8,rate_per_minute=$9,context_window_k=$10,reasoning_effort=$11,priority=$12,pool_exclude=$13,thinking_type=$14,streaming=$15 WHERE id=$16`,
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.APIKey, hint, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.Streaming, p.ID)
return p.ID, err
}
+34
View File
@@ -672,6 +672,40 @@ ORDER BY id DESC LIMIT $4`, s.expID, kind, before, limit+1)
return nodes, hasMore, nil
}
// listByKindPageFiltered is ListByKindPage with an optional summary keyword
// filter (payload->>'summary' ILIKE %q%). Fetches one extra row so the caller can
// probe hasMore. Empty q = no filter. Newest-first by id.
func (s *ExplorationStore) listByKindPageFiltered(kind string, before int64, limit int, q string) ([]*Node, error) {
where := `exploration_id=$1 AND kind=$2 AND ($3 <= 0 OR id < $3)`
args := []any{s.expID, kind, before}
if q != "" {
args = append(args, "%"+q+"%")
where += fmt.Sprintf(` AND payload->>'summary' ILIKE $%d`, len(args))
}
args = append(args, limit+1)
rows, err := s.db.Query(`SELECT `+nodeCols+` FROM exploration_nodes
WHERE `+where+` ORDER BY id DESC LIMIT $`+fmt.Sprintf("%d", len(args)), args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanNodes(rows)
}
// countByKindFiltered counts nodes of a kind under the same summary filter, for a
// page's total. Empty q = count all of that kind.
func (s *ExplorationStore) countByKindFiltered(kind, q string) (int, error) {
where := `exploration_id=$1 AND kind=$2`
args := []any{s.expID, kind}
if q != "" {
args = append(args, "%"+q+"%")
where += fmt.Sprintf(` AND payload->>'summary' ILIKE $%d`, len(args))
}
var n int
err := s.db.QueryRow(`SELECT COUNT(*) FROM exploration_nodes WHERE `+where, args...).Scan(&n)
return n, err
}
// GetNode returns one node of this exploration by id (nil, nil if not found).
func (s *ExplorationStore) GetNode(id int64) (*Node, error) {
n, err := scanNode(s.db.QueryRow(`SELECT `+nodeCols+` FROM exploration_nodes WHERE id=$1 AND exploration_id=$2`, id, s.expID))
+47
View File
@@ -108,6 +108,53 @@ func (s *ExplorationStore) ListByKindWithSources(kind string, limit int) ([]*Nod
return out, nil
}
// ListByKindPageWithSources is the paginated, keyword-filterable sibling of
// ListByKindWithSources: it returns one newest-first page (id < before, before<=0
// = newest) spanning this exploration and its direct sources, plus hasMore and the
// filtered total across all of them. Node ids are globally unique, so merging each
// store's own page and re-sorting by id DESC yields the true global page; fetching
// limit+1 per store guarantees the merged top-`limit` is complete.
func (s *ExplorationStore) ListByKindPageWithSources(kind string, before int64, limit int, q string) (nodes []*Node, hasMore bool, total int, err error) {
if limit <= 0 {
limit = 20
}
own, err := s.listByKindPageFiltered(kind, before, limit, q)
if err != nil {
return nil, false, 0, err
}
merged := own
total, err = s.countByKindFiltered(kind, q)
if err != nil {
return nil, false, 0, err
}
sources, err := s.DirectSourceStores()
if err != nil {
return nil, false, 0, err
}
for _, source := range sources {
page, err := source.Store.listByKindPageFiltered(kind, before, limit, q)
if err != nil {
return nil, false, 0, err
}
for _, n := range page {
merged = append(merged, markInheritedNode(n, source.Task.TaskID))
}
cnt, err := source.Store.countByKindFiltered(kind, q)
if err != nil {
return nil, false, 0, err
}
total += cnt
}
sort.Slice(merged, func(i, j int) bool { return merged[i].ID > merged[j].ID })
hasMore = len(merged) > limit
if hasMore {
merged = merged[:limit]
}
return merged, hasMore, total, nil
}
// GetNodeWithSources reads a node only when it belongs to this exploration or
// one of its direct sources. Inherited nodes are tagged so tool callers can keep
// them read-only and show their provenance.
+4
View File
@@ -310,6 +310,8 @@ CREATE TABLE IF NOT EXISTS llm_profiles (
-- pool_exclude true=不作为故障转移目标(仍可被 agent/任务显式绑定使用)。
priority INTEGER NOT NULL DEFAULT 0,
pool_exclude BOOLEAN NOT NULL DEFAULT false,
-- streaming=true(默认)走流式 SSE;false 走真·非流式(stream:false,一次性 JSON)。
streaming BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
@@ -320,6 +322,8 @@ CREATE TRIGGER trg_llm_upd BEFORE UPDATE ON llm_profiles
-- 轮询顺位/排除标记;补旧库。默认 0 / false = 全部配置都参与轮询。
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS priority INTEGER NOT NULL DEFAULT 0;
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS pool_exclude BOOLEAN NOT NULL DEFAULT false;
-- 流式开关;补旧库。默认 true = 保持既有的流式行为,旧配置无感升级。
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS streaming BOOLEAN NOT NULL DEFAULT true;
-- 思考开关字段 thinking_type,从旧的单一 reasoning_effort 语义一次性拆分而来。
-- schema.sql 每次启动都执行,故迁移必须只跑一次:仅当该列尚不存在时才回填,