Compare commits

...
Author SHA1 Message Date
weichen 1a5ef6b75c Anchor work decisions to a reserved follow-up run identity
Why: Reconcile interrupted dispatch without spending another model request.

Evidence: Core package tests pass locally; integration exercises acceptance and recovery.

Impact: Add one optional persisted run identifier.

Refs: feature/workbench-flow-completion
2026-10-15 06:53:28 +00:00
weichen fcfe8149bd Verify durable work results and legacy card compatibility
Why: Retain full results and exact answers across persistence and reload.

Evidence: go test ./... passed locally, including full-tail and legacy JSON coverage.

Impact: Add contract roundtrip tests and a guarded dispatching state.

Refs: feature/workbench-flow-completion
2026-10-15 06:49:33 +00:00
weichen 00f182611c Define durable nonblocking work card snapshots
Why: Keep background results and optional decisions outside the foreground conversation lock.

Evidence: Core package tests and combination checks are recorded in the Dev task.

Impact: Add an optional backward-compatible work card payload without a database migration.

Refs: feature/workbench-flow-completion
2026-10-15 06:46:23 +00:00
weichen 5773e74086 Merge pull request 'Add managed scope to authoritative SettingDesc TS mirror' (#32) from fix/settingdesc-managed-scope into main 2026-10-13 06:54:12 +00:00
weichen d6f62af0ed Add managed scope to authoritative SettingDesc TS mirror
Why: Dev 组合构建经 make contracts 从 Core ts/types.ts 重新生成 frontend/ui 的 contracts.ts——受管 scope 字段此前只改了 frontend 镜像,构建时被权威源覆盖丢失(TS2339)。
Evidence: go test ./... 全绿;生成链路验证随 Dev 组合 CI。
Impact: 权威 TS 类型加可选字段;Go 契约(SettingDesc.Scope)此前已随 Core #30 合入,两侧对齐。
2026-10-13 06:53:42 +00:00
weichen 0f94e4b062 Merge pull request 'Add system-managed setting scope contract to SettingDesc' (#30) from feature/rdm-increment-settings-duties into main 2026-10-13 04:19:40 +00:00
weichen aced665ba6 Merge origin/main into feature/rdm-increment-settings-duties 2026-10-13 04:19:24 +00:00
weichen 9150e6f90b Add system-managed setting scope contract to SettingDesc
Why: P2 受管设置机制需要 schema 声明写入归属——system 受管项仅管理员可写,user/project 层拒绝写入且生效链忽略存量覆盖。
Evidence: go test ./... 全绿(ManagedPaths/Touches/Strip 含祖先标量覆盖与整棵子树剥离用例)。
Impact: 新增 SettingScope 常量、SettingDesc.Scope 字段与三个纯函数;空 Scope 保持既有行为,向后兼容。
Refs: modules/documents/docs/proposals/2026-10-13-rdm-settings-members-duties-perms-increment.md P2
2026-10-13 03:40:25 +00:00
weichen 8e4f4e0382 Merge pull request 'Add U1 task summary body contract helpers' (#29) from feature/fast-slow-display-u1 into main
Reviewed-on: https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Core/pulls/29
2026-10-12 12:16:33 +00:00
5 changed files with 319 additions and 15 deletions
+46 -13
View File
@@ -12,19 +12,52 @@ import "time"
type ActivityCard struct {
ID ID `json:"id"`
// ProjectID/ConversationID 定位归属;ConversationID 为空表示项目级卡片(远期跨会话收件箱)。
ProjectID ID `json:"projectId"`
ConversationID ID `json:"conversationId,omitempty"`
SourceRunID ID `json:"sourceRunId,omitempty"` // 溯源:产生卡片的 run
AnchorMessageID ID `json:"anchorMessageId,omitempty"` // 互定位:出生消息(↙ 定位到对话)
Kind CardKind `json:"kind"`
Title string `json:"title"`
Status CardStatus `json:"status"`
Tone string `json:"tone,omitempty"` // ok | warn | danger | info
Body []UINode `json:"body,omitempty"`
Actions []UIAction `json:"actions,omitempty"` // 走命令总线;未绑定 command 降级为发消息
Pinned bool `json:"pinned,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ProjectID ID `json:"projectId"`
ConversationID ID `json:"conversationId,omitempty"`
SourceRunID ID `json:"sourceRunId,omitempty"` // 溯源:产生卡片的 run
AnchorMessageID ID `json:"anchorMessageId,omitempty"` // 互定位:出生消息(↙ 定位到对话)
Kind CardKind `json:"kind"`
Title string `json:"title"`
Status CardStatus `json:"status"`
Tone string `json:"tone,omitempty"` // ok | warn | danger | info
Body []UINode `json:"body,omitempty"`
Actions []UIAction `json:"actions,omitempty"` // 走命令总线;未绑定 command 降级为发消息
Pinned bool `json:"pinned,omitempty"`
Work *WorkCard `json:"work,omitempty"` // optional background-work snapshot; never a foreground approval
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// WorkCard keeps the complete saved result and an optional, non-blocking next step
// on the original task. Reading and answering do not change the result version.
type WorkCard struct {
Result string `json:"result,omitempty"`
ResultVersion int `json:"resultVersion"`
ReadVersion int `json:"readVersion"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
Decision *WorkDecision `json:"decision,omitempty"`
}
type WorkDecision struct {
RunID ID `json:"runId,omitempty"` // reserved child identity; reconciles acceptance after restart without rerunning
Mode string `json:"mode"` // single | multiple
State string `json:"state"` // open | deferred | dispatching | submitted
Options []WorkOption `json:"options,omitempty"`
SelectedIDs []string `json:"selectedIds,omitempty"`
FreeText string `json:"freeText,omitempty"`
RespondedAt *time.Time `json:"respondedAt,omitempty"`
}
type WorkOption struct {
ID string `json:"id"`
Label string `json:"label"`
}
// WorkCardAction is version-fenced. Presentation-only opens never mark a result read.
type WorkCardAction struct {
ResultVersion int `json:"resultVersion"`
SelectedIDs []string `json:"selectedIds,omitempty"`
FreeText string `json:"freeText,omitempty"`
}
type CardKind string
+46
View File
@@ -0,0 +1,46 @@
package core
import (
"encoding/json"
"strings"
"testing"
"time"
)
func TestWorkCardSurvivesPersistedSnapshot(t *testing.T) {
completed := time.Date(2026, 10, 15, 6, 0, 0, 0, time.UTC)
card := ActivityCard{ID: "card_work", Status: CardDone, Work: &WorkCard{
Result: strings.Repeat("完整结果\n", 200) + "最后一项依据",
ResultVersion: 2, ReadVersion: 1, CompletedAt: &completed,
Decision: &WorkDecision{Mode: "multiple", State: "submitted", Options: []WorkOption{{ID: "summary", Label: "生成摘要"}},
SelectedIDs: []string{"summary"}, FreeText: "请突出关键限制", RespondedAt: &completed},
}}
data, err := json.Marshal(card)
if err != nil {
t.Fatal(err)
}
var restored ActivityCard
if err := json.Unmarshal(data, &restored); err != nil {
t.Fatal(err)
}
if restored.Work == nil || restored.Work.Result != card.Work.Result || restored.Work.ReadVersion != 1 || restored.Work.ResultVersion != 2 {
t.Fatalf("complete result or unread version lost: %+v", restored.Work)
}
if d := restored.Work.Decision; d.State != "submitted" || d.SelectedIDs[0] != "summary" || d.FreeText != "请突出关键限制" || !d.RespondedAt.Equal(completed) {
t.Fatalf("decision history lost: %+v", d)
}
}
func TestLegacyCardOmitsOptionalWork(t *testing.T) {
var legacy ActivityCard
if err := json.Unmarshal([]byte(`{"id":"old","status":"resolved","body":[{"type":"markdown","markdown":"保留历史"}]}`), &legacy); err != nil {
t.Fatal(err)
}
data, err := json.Marshal(legacy)
if err != nil {
t.Fatal(err)
}
if legacy.Work != nil || strings.Contains(string(data), `"work"`) || legacy.Body[0].Markdown != "保留历史" {
t.Fatalf("legacy payload changed: %s", data)
}
}
+125 -1
View File
@@ -1,6 +1,9 @@
package core
import "encoding/json"
import (
"encoding/json"
"strings"
)
// 智能体画像(4.7):核心定制机制,全部数据化、可导入导出。
type AgentProfile struct {
@@ -107,6 +110,16 @@ const (
EffectRestart SettingEffect = "restart" // 需重启
)
// SettingScope 声明设置项的写入归属(提案 2026-10-13-rdm-settings-members-duties-perms-increment P2)。
// 空串 = 默认:user 与 project 层可写(向后兼容既有 schema)。
type SettingScope string
const (
// SettingScopeSystem 受管项:仅系统管理面(admin)可写;user/project 写入被拒,
// 生效链忽略这两层的存量覆盖值。
SettingScopeSystem SettingScope = "system"
)
// SettingDesc 描述一个设置项;设置页按此渲染并标注生效时机(schema 驱动)。
type SettingDesc struct {
Key string `json:"key"`
@@ -118,6 +131,117 @@ type SettingDesc struct {
Options []string `json:"options,omitempty"` // enum
Effect SettingEffect `json:"effect"`
Secret bool `json:"secret,omitempty"`
Scope SettingScope `json:"scope,omitempty"` // 空 = user/project 可写;system = 受管
}
// ManagedPaths 返回 schema 中 scope=system 的键路径(点路径形式)。
func ManagedPaths(descs []SettingDesc) []string {
out := make([]string, 0, len(descs))
for _, d := range descs {
if d.Scope == SettingScopeSystem {
out = append(out, d.Key)
}
}
return out
}
// FlattenSettingLeafPaths 列出 values 的全部叶子点路径。子树为标量或数组时该子树
// 根即叶子。用于受管触碰检测:深合并语义下叶子决定最终覆盖面。
func FlattenSettingLeafPaths(values map[string]any) []string {
var out []string
var walk func(prefix string, m map[string]any)
walk = func(prefix string, m map[string]any) {
for k, v := range m {
path := k
if prefix != "" {
path = prefix + "." + k
}
if vm, ok := v.(map[string]any); ok && len(vm) > 0 {
walk(path, vm)
continue
}
out = append(out, path)
}
}
walk("", values)
return out
}
// pathTouchesManaged 判断 path 是否触碰 managed:等于受管路径、位于受管子树内
// (受管路径是 path 的前缀),或整体覆盖受管子树(path 是受管路径的前缀——
// 标量覆盖整棵受管 map 同样破坏受管值)。
func pathTouchesManaged(path string, managed []string) bool {
for _, mp := range managed {
if path == mp ||
strings.HasPrefix(path, mp+".") ||
strings.HasPrefix(mp, path+".") {
return true
}
}
return false
}
// TouchesManagedSettings 返回 values 中触碰受管路径的叶子;空切片 = 无触碰。
// 调用方须传入已展开点路径的嵌套 map(normalize 后)。
func TouchesManagedSettings(values map[string]any, managed []string) []string {
var hits []string
for _, p := range FlattenSettingLeafPaths(values) {
if pathTouchesManaged(p, managed) {
hits = append(hits, p)
}
}
return hits
}
// StripManagedSettings 返回删除受管路径后的浅拷贝(不改入参)。受管路径的祖先
// 若因此只剩空 map 一并删除。入参须为嵌套形式(点路径已展开)。
func StripManagedSettings(values map[string]any, managed []string) map[string]any {
if len(values) == 0 || len(managed) == 0 {
return values
}
// 叶子命中或祖先整体命中都删;逐叶子重建最简单可靠。
leaves := FlattenSettingLeafPaths(values)
keep := map[string]any{}
for _, leaf := range leaves {
if pathTouchesManaged(leaf, managed) {
continue
}
setPath(keep, leaf, lookupAny(values, leaf))
}
return keep
}
func setPath(m map[string]any, path string, val any) {
parts := strings.Split(path, ".")
cur := m
for i, p := range parts {
if i == len(parts)-1 {
cur[p] = val
return
}
next, ok := cur[p].(map[string]any)
if !ok {
next = map[string]any{}
cur[p] = next
}
cur = next
}
}
func lookupAny(m map[string]any, path string) any {
parts := strings.Split(path, ".")
var cur any = m
for _, p := range parts {
cm, ok := cur.(map[string]any)
if !ok {
return nil
}
cur, ok = cm[p]
if !ok {
return nil
}
}
return cur
}
// EffectiveSettings 是覆盖链(system → user → project → conversation)深合并结果。
+85
View File
@@ -0,0 +1,85 @@
package core
import (
"reflect"
"sort"
"testing"
)
func TestManagedPathsFiltersSystemScope(t *testing.T) {
descs := []SettingDesc{
{Key: "models.chat.model", Scope: SettingScopeSystem},
{Key: "ui.theme"},
{Key: "tools.tavily.apiKeys", Scope: SettingScopeSystem},
}
got := ManagedPaths(descs)
sort.Strings(got)
want := []string{"models.chat.model", "tools.tavily.apiKeys"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ManagedPaths = %v, want %v", got, want)
}
}
func TestTouchesManagedSettings(t *testing.T) {
managed := []string{"models.chat.model", "tools.tavily.apiKeys", "telemetry.otlpEndpoint"}
cases := []struct {
name string
in map[string]any
want []string
}{
{"exact leaf", map[string]any{"models": map[string]any{"chat": map[string]any{"model": "x"}}}, []string{"models.chat.model"}},
{"sibling untouched", map[string]any{"models": map[string]any{"chat": map[string]any{"baseUrl": "x"}}}, nil},
{"ancestor scalar overwrite", map[string]any{"models": "oops"}, []string{"models"}},
{"subtree inside managed", map[string]any{"telemetry": map[string]any{"otlpEndpoint": "http://x"}}, []string{"telemetry.otlpEndpoint"}},
{"other group untouched", map[string]any{"ui": map[string]any{"theme": "light"}}, nil},
{"empty map", map[string]any{}, nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := TouchesManagedSettings(tc.in, managed)
sort.Strings(got)
if tc.want == nil && len(got) != 0 {
t.Fatalf("TouchesManagedSettings = %v, want none", got)
}
if tc.want != nil && !reflect.DeepEqual(got, tc.want) {
t.Fatalf("TouchesManagedSettings = %v, want %v", got, tc.want)
}
})
}
}
func TestStripManagedSettings(t *testing.T) {
managed := []string{"models.chat.model", "models.chat.apiKey"}
in := map[string]any{
"models": map[string]any{
"chat": map[string]any{"model": "x", "baseUrl": "https://b"},
"lightweight": map[string]any{"model": "y"},
},
"ui": map[string]any{"theme": "dark"},
}
got := StripManagedSettings(in, managed)
want := map[string]any{
"models": map[string]any{
"chat": map[string]any{"baseUrl": "https://b"},
"lightweight": map[string]any{"model": "y"},
},
"ui": map[string]any{"theme": "dark"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("StripManagedSettings = %v, want %v", got, want)
}
if in["models"].(map[string]any)["chat"].(map[string]any)["model"] != "x" {
t.Fatal("StripManagedSettings 不应修改入参")
}
}
func TestStripManagedSettingsWholeSubtree(t *testing.T) {
managed := []string{"models.chat.model", "models.chat.baseUrl", "models.chat.apiKey", "models.chat.fallbacks"}
in := map[string]any{
"models": map[string]any{"chat": map[string]any{"model": "x", "baseUrl": "b", "apiKey": "k"}},
}
got := StripManagedSettings(in, managed)
if len(got) != 0 {
t.Fatalf("整棵受管子树应被剥除(含空父),got %v", got)
}
}
+17 -1
View File
@@ -150,7 +150,21 @@ export interface ActivityCard {
sourceRunId?: ID; anchorMessageId?: ID;
kind: CardKind; title: string; status: CardStatus; tone?: string;
body?: UINode[]; actions?: { id: string; label: string; style?: 'primary' | 'default' | 'danger'; command?: string }[];
pinned?: boolean; createdAt: string; updatedAt: string;
pinned?: boolean; createdAt: string; updatedAt: string; work?: WorkCard;
}
export interface WorkCard {
result?: string; resultVersion: number; readVersion: number; completedAt?: string;
decision?: WorkDecision;
}
export interface WorkDecision {
runId?: ID;
mode: 'single' | 'multiple'; state: 'open' | 'deferred' | 'dispatching' | 'submitted';
options?: { id: string; label: string }[];
selectedIds?: string[]; freeText?: string; respondedAt?: string;
}
export interface WorkCardAction {
resultVersion: number; selectedIds?: string[]; freeText?: string;
}
export interface CardPayload { card: ActivityCard }
@@ -269,6 +283,8 @@ export interface SettingDesc {
default?: unknown; options?: string[];
effect: 'hot' | 'new-run' | 'restart';
secret?: boolean;
/** P2 受管:system = 系统受管项,仅系统管理面可写(用户/项目层拒绝)。 */
scope?: 'system';
}
// ---- 插件 ----