Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
8 changes: 8 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ _Avoid_: 编排引擎、调度

编排中的两类工位:Explorer 只读,做探索、规划、评审;Worker 可写,做实现。各有简单、困难两档,Worker 另有界面简单、困难两档,用于页面、样式、交互和看图的实现,没设时沿用 Worker 同档;用户在 App 选 CLI、模型和可选档位,存于 `orchestration.json`;当前接入行启用编排时,`list_members` 的 `orchestration` 才返回工位及可用性,派发才套用角色默认值,boss 按任务难度选档,涉及界面的实现改用界面档。关闭保留已保存的选择。与宿主同类的工位标 `native=true`,交给宿主自带的子代理,评审工位同样如此。

## 生图工位

设置页常驻、独立于普通自定义角色的可选图像生成工位,用户为它选定 CLI 及与之对话的模型;实际图像由该 CLI 的模型调用自身可用的生图服务生成。它独立于 Explorer/Worker 的难度档,未配置时没有默认绑定;生图编排规则与工位反馈仅在选定后向不同类 CLI 的宿主提供;同类宿主不展示此工位。

## 权限档位(Permission Tier)

每个 CLI 的派发权限:自动(缺省)或完全放开,用户在 App 中改选,存于独立的 `permissions.json`,私聊与会议共用,与模型无关。服务把它映射为该 CLI 的原生权限选项并在回执注明;调用方显式给出权限选项时不套用。「自动」交给 CLI 自带的自动审核,CLI 没有可用的自动审核时等同完全放开。Pi 没有审批步骤,不设档位。
Expand Down Expand Up @@ -154,3 +158,7 @@ _Avoid_: 控制台、桌面客户端
## 语音播报

按模型家族音色朗读右侧消息摘要的可选功能,覆盖已有的 boss 发言与员工回答;可手动选择消息,点读后的连播仅限所选那场会议,包含其全部轮次,不串播其他会议。排队时一次只播一个 boss 的消息,播完再换下一个;手选的消息插到最前。每场会议或每段私聊是一张专辑,封面是其中所有参与者,与从哪条消息开始播放无关。播放不随切换视图、关闭对话栏或隐藏窗口中断。**收听**是用户主动开启、限时跟读一个工作区、一场会议或一段私聊的播放状态:一场会议或一段私聊从头听全,本次运行内再开从上次念完处继续;工作区只读开启之后的新消息;同一时间只收听一个,隐藏办公室或切换视图不影响收听,重启即结束。到期后当前正在念的消息念完,后续收听内容撤下,手动点读与连播不受影响。独立语音频道用头像区分主持人和发言人,不改变办公室的小人动画与任务状态。模型 prompt 定义消息缩写规则,小人人设定义性格、措辞与口吻;语音以发言者本人第一人称表达。boss 发言的头像和音色跟实际说话的 boss 走,接手工作区不改变已说过的话,新 boss 在会议里发言后才换。

## CLI 可用模型池

用户在员工页为每个 CLI 维护的共用模型边界,普通任务、编排工位与会议都从其中选择模型。工位绑定和场景默认值不再各自拥有允许名单;宿主启用编排时,普通任务候选与场景默认值退出 boss 的编排输入,关闭后恢复。
2 changes: 1 addition & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ On macOS and Linux, Claude runs interactively by default, which saves subscripti

### 🧭 Seats and review

Pick models for the Explorer and Worker seats in the App, with tiers for easy, hard and interface work, and the boss dispatches by difficulty. You can also add a [review before merging](skills/agentnave-review/SKILL.md): how much review a change gets depends on its risk, reviewers read but never edit, and their conclusions are kept as a record.
Pick models for the Explorer and Worker seats in the App, with tiers for easy, hard and interface work, and the boss dispatches by difficulty. An optional image generation seat selects a CLI and its conversation model, which calls its own image service. You can also add a [review before merging](skills/agentnave-review/SKILL.md): how much review a change gets depends on its risk, reviewers read but never edit, and their conclusions are kept as a record.

### 🤝 Continue in another chat

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@

### 🧭 工位和评审

在 App 里给 Explorer、Worker 工位选好模型,简单、困难和界面任务各有档位,boss 按任务难度派活。还可以加一道[合并前评审](skills/agentnave-review/SKILL.md):按风险大小决定派谁来看,评审的员工只读不改,结论留作记录。
在 App 里给 Explorer、Worker 工位选好模型,简单、困难和界面任务各有档位,boss 按任务难度派活。可选的生图工位只需选 CLI 和对话模型,由它调用自身的生图服务。还可以加一道[合并前评审](skills/agentnave-review/SKILL.md):按风险大小决定派谁来看,评审的员工只读不改,结论留作记录。

### 🤝 换个聊天接着干

Expand Down
2 changes: 1 addition & 1 deletion apps/tray/src/badge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ const DRIP: [&[u8]; 18] = [
];
/// Font choices and the macOS fonts they draw with; the first is the fallback.
const FONTS: [(&str, &str); 4] = [
("futura", "Futura-Medium"),
("baskerville", "Baskerville-SemiBoldItalic"),
("futura", "Futura-Medium"),
("typewriter", "AmericanTypewriter-Semibold"),
("marker", "MarkerFelt-Wide"),
];
Expand Down
2 changes: 1 addition & 1 deletion apps/tray/src/data/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ impl Reader {
};
let rules = self.rule_files();
Response::json(&json!({"document": document, "error": error,
"presets": crate::rules::presets(), "files": rules["files"], "bosses": rules["bosses"]}))
"presets": crate::rules::presets(&document), "files": rules["files"], "bosses": rules["bosses"]}))
}
pub(super) fn save_orchestration(&self, payload: &Value) -> Response {
// The modes each row runs on are worked out here, never taken from the page.
Expand Down
47 changes: 43 additions & 4 deletions apps/tray/src/members.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,13 @@ pub fn validate(document: &Value) -> Result<(), String> {
let default = default
.as_str()
.ok_or_else(|| format!("{what}.default 必须为字符串"))?;
if !known.contains_key(default) || !(listed.is_empty() || listed.contains(&default))
// A CLI's own list replaces the scene's. A default outside it stays readable: the
// App reports it and the server refuses to run it.
let pooled = clis
.get(provider)
.is_some_and(|cli| cli.get("allowed_models").is_some());
if !known.contains_key(default)
|| !(pooled || listed.is_empty() || listed.contains(&default))
{
return Err(format!("{what} 的默认模型 {default} 不在允许的模型中"));
}
Expand Down Expand Up @@ -290,8 +296,9 @@ fn validate_seat(seat: &Value, what: &str) -> Result<(), String> {

/// orchestration.json: `{"version": 1, "preset": "off" | "split" | "parallel" | "custom",
/// "custom": text, "apps": {row: mode}, "effective_apps": {row: mode}, "explorer": {"easy" | "hard":
/// seat}, "worker": {...}, "worker_frontend": {...}}`. Interface Workers have a key of their own so
/// servers that predate it ignore them rather than reject the file. The App writes the chosen
/// seat}, "worker": {...}, "worker_frontend": {...}, "image_generation": seat}`. Interface Workers
/// and the image seat have keys of their own so servers that predate them ignore them rather than
/// reject the file. The App writes the chosen
/// rules into the boss apps' global rules and
/// `effective_apps`, every connected row's mode as its group applies it, for the server.
pub fn validate_orchestration(document: &Value) -> Result<(), String> {
Expand All @@ -307,6 +314,7 @@ pub fn validate_orchestration(document: &Value) -> Result<(), String> {
"explorer",
"worker",
"worker_frontend",
"image_generation",
],
ORCHESTRATION_FILE,
)?;
Expand Down Expand Up @@ -346,6 +354,9 @@ pub fn validate_orchestration(document: &Value) -> Result<(), String> {
validate_seat(seat, &format!("{role}.{difficulty}"))?;
}
}
if let Some(seat) = top.get("image_generation").filter(|seat| !seat.is_null()) {
validate_seat(seat, "image_generation")?;
}
Ok(())
}

Expand Down Expand Up @@ -612,7 +623,11 @@ fn validate_clis(clis: &Map<String, Value>) -> Result<(), String> {
return Err(format!("不支持的 CLI:{provider}"));
}
let fields = object(cli, &format!("clis.{provider}"))?;
only(fields, &["models"], &format!("clis.{provider}"))?;
only(
fields,
&["models", "allowed_models"],
&format!("clis.{provider}"),
)?;
let models = fields
.get("models")
.ok_or_else(|| format!("clis.{provider} 缺少 models"))?;
Expand All @@ -624,6 +639,30 @@ fn validate_clis(clis: &Map<String, Value>) -> Result<(), String> {
settings(shared, &what)?;
effort_allowed(shared, None, &what)?;
}
if let Some(pool) = fields.get("allowed_models").filter(|pool| !pool.is_null()) {
validate_pool(pool, object(models, "models")?, provider)?;
}
}
Ok(())
}

/// The models a CLI may run, for every scene and seat; absent or null means any.
fn validate_pool(pool: &Value, models: &Map<String, Value>, provider: &str) -> Result<(), String> {
let what = format!("clis.{provider}.allowed_models");
let pool = pool
.as_array()
.filter(|pool| !pool.is_empty())
.ok_or_else(|| format!("{what} 必须为非空数组"))?;
for (index, model) in pool.iter().enumerate() {
let name = model
.as_str()
.ok_or_else(|| format!("{what} 只能含字符串"))?;
if !models.contains_key(name) {
return Err(format!("{what} 引用了未配置的模型 {name}"));
}
if pool[..index].contains(model) {
return Err(format!("{what} 重复列出模型 {name}"));
}
}
Ok(())
}
Expand Down
23 changes: 23 additions & 0 deletions apps/tray/src/members/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,29 @@ fn accepts_the_contract_example_and_rejects_structural_or_reference_errors() {
}
}

#[test]
fn a_cli_pool_is_checked_and_replaces_the_scene_list_for_defaults() {
let mut pooled = document();
pooled["clis"]["codex"]["allowed_models"] = json!(["gpt-6.1-sol"]);
validate(&pooled).unwrap();
pooled["clis"]["codex"]["allowed_models"] = Value::Null;
validate(&pooled).unwrap();
for pool in [
json!([]),
json!("gpt-6.1-sol"),
json!([1]),
json!(["missing"]),
json!(["gpt-6.1-sol", "gpt-6.1-sol"]),
] {
pooled["clis"]["codex"]["allowed_models"] = pool.clone();
assert!(validate(&pooled).is_err(), "{pool}");
}
// A default outside the pool or an older scene list stays readable for the App to report.
pooled["clis"]["codex"]["allowed_models"] = json!(["gpt-6.1-sol"]);
pooled["modes"]["task"]["codex"]["default"] = json!("gpt-6-luna");
validate(&pooled).unwrap();
}

#[test]
fn saves_atomically_through_the_post_route_only() {
let directory = tempfile::tempdir().unwrap();
Expand Down
2 changes: 1 addition & 1 deletion apps/tray/src/preferences/general.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ pub fn effective(document: &Value, autostart: bool) -> Value {
let mut result = json!({
"language": "system", "theme": "system", "motion": "system", "scale": 100,
"autostart": autostart, "open_on_start": true, "tray_click": "panel",
"tray_count": true, "tray_font": "futura", "keep_awake": false,
"tray_count": true, "tray_font": "baskerville", "keep_awake": false,
});
if let Some(saved) = document["general"].as_object() {
for (field, value) in saved {
Expand Down
6 changes: 4 additions & 2 deletions apps/tray/src/preferences/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,14 @@ fn effective_fills_defaults_and_uses_the_os_autostart_value() {
json!({
"language": "system", "theme": "system", "motion": "system", "scale": 100,
"autostart": true, "open_on_start": true, "tray_click": "panel",
"tray_count": true, "tray_font": "futura", "keep_awake": false,
"tray_count": true, "tray_font": "baskerville", "keep_awake": false,
})
);
let effective = general::effective(
&json!({"general": {
"autostart": true, "scale": 115, "theme": "light", "motion": "full",
"open_on_start": false, "tray_click": "office", "tray_count": false, "keep_awake": true,
"open_on_start": false, "tray_click": "office", "tray_count": false,
"tray_font": "futura", "keep_awake": true,
"future": 1
}}),
false,
Expand All @@ -126,6 +127,7 @@ fn effective_fills_defaults_and_uses_the_os_autostart_value() {
assert_eq!(effective["motion"], "full");
assert_eq!(effective["tray_click"], "office");
assert_eq!(effective["tray_count"], false);
assert_eq!(effective["tray_font"], "futura");
assert!(effective.get("future").is_none());
assert_eq!(
general::effective(
Expand Down
51 changes: 36 additions & 15 deletions apps/tray/src/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,17 @@ const SHARED: &str = "## AgentNave 编排偏好
- 本偏好不决定何时评审,用户要求或其规则要求时才评审。评审时有 agentnave-review Skill 就按它定级、选工位,不另派 Explorer;没有时派 Explorer,不交给写这段代码的 Worker,尽量换模型。
- `succeeded` 不等于完成,boss 核对产物后验收;失败或取消时先核对现状,再决定续接、重派还是收尾。";

const IMAGE_GENERATION: &str = "\n- 生成图片派生图工位,`seat` 用 `image-generator`,prompt 写清图像需求和产物路径,由它的对话模型自行调用生图服务。";

const PARALLEL: &str = "
- 计划确认后,互不依赖的写入步骤并行派给多个 Worker,在 Git 仓库里各用独立 worktree;无法隔离时串行。boss 合入当前分支,最后统一验收。
- 有实质取舍或分歧时进会议室出决定,会上不改代码。
- 不为每次实现另派复核;高风险改动由 boss 针对具体的出错方式追加验证。
- 没有新证据或待解问题时,不再追加讨论、重试和验证。";

/// Each preset's rules text, for the settings page and the custom editor's starting text.
pub fn presets() -> Value {
json!({"split": SHARED, "parallel": format!("{SHARED}{PARALLEL}")})
pub fn presets(document: &Value) -> Value {
json!({"split": text_of(document, "split"), "parallel": text_of(document, "parallel"), "image_generation": IMAGE_GENERATION})
}

/// The mode of apps sharing a file: the first set among them, or the default that rows without
Expand All @@ -45,14 +47,21 @@ fn mode_of<'a>(document: &'a Value, group: &[&App]) -> &'a str {

/// The text a mode puts in the rules files, if any.
fn text_of(document: &Value, mode: &str) -> Option<String> {
match mode {
"split" => Some(SHARED.into()),
"parallel" => Some(format!("{SHARED}{PARALLEL}")),
"custom" => Some(document["custom"].as_str()?.trim())
.filter(|text| !text.is_empty())
.map(Into::into),
_ => None,
let mut text = match mode {
"split" => SHARED.to_owned(),
"parallel" => format!("{SHARED}{PARALLEL}"),
"custom" => document["custom"].as_str()?.trim().to_owned(),
_ => return None,
};
if text.is_empty() {
return None;
}
// Strip our generated line if it was copied into a custom preset before being cleared.
text = text.replace(IMAGE_GENERATION, "");
if document["image_generation"].is_object() {
text.push_str(IMAGE_GENERATION);
}
Some(text)
}

/// Custom text goes into every boss app's rules each turn, so it stays short and cannot end the
Expand Down Expand Up @@ -266,12 +275,24 @@ pub fn effective(connected: &[App], home: &Path, document: &Value) -> Value {
fn wanted(connected: &[App], home: &Path, document: &Value) -> Vec<(PathBuf, String)> {
let mut wanted = Vec::new();
for group in groups(connected, home) {
if let Some(text) = text_of(document, mode_of(document, &group)) {
wanted.extend(
targets(&group, home)
.into_iter()
.map(|path| (path, text.clone())),
);
for path in targets(&group, home) {
let mut scoped = document.clone();
let provider = document["image_generation"]["provider"].as_str();
// A shared rules file cannot give one of its readers a rule it must not see.
let own_reader = group.iter().any(|app| {
let cli = if app.id == "workbuddy" {
"codebuddy"
} else {
app.id
};
provider == Some(cli) && would_read(app, home).contains(&path)
});
if own_reader {
scoped["image_generation"] = Value::Null;
}
if let Some(text) = text_of(&scoped, mode_of(document, &group)) {
wanted.push((path, text));
}
}
}
wanted
Expand Down
Loading
Loading