@team-agent/installer 0.5.101 → 0.5.103
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/emit.rs +2 -1
- package/crates/team-agent/src/cli/models.rs +92 -226
- package/crates/team-agent/src/cli/spec.rs +1 -1
- package/crates/team-agent/src/cli/tests/main_preserved.rs +0 -74
- package/crates/team-agent/src/compiler/tests.rs +20 -23
- package/crates/team-agent/src/compiler.rs +49 -65
- package/crates/team-agent/src/coordinator/tests/s3_bounded.rs +16 -48
- package/crates/team-agent/src/leader/start.rs +1 -14
- package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +37 -161
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +1 -1
- package/crates/team-agent/src/lifecycle/restart/common.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/host_cotenant_death_p0_contract.rs +0 -28
- package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +11 -9
- package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +8 -401
- package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +2 -2
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +22 -8
- package/crates/team-agent/src/mcp_server/helpers.rs +0 -16
- package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +2 -7
- package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +1 -127
- package/crates/team-agent/src/mcp_server/mod.rs +7 -12
- package/crates/team-agent/src/mcp_server/tests/scoped.rs +1 -132
- package/crates/team-agent/src/mcp_server/tests/send.rs +1 -93
- package/crates/team-agent/src/mcp_server/tests/tools.rs +0 -61
- package/crates/team-agent/src/mcp_server/tests/wire.rs +6 -51
- package/crates/team-agent/src/mcp_server/tests.rs +4 -200
- package/crates/team-agent/src/mcp_server/tools.rs +5 -396
- package/crates/team-agent/src/mcp_server/types.rs +3 -36
- package/crates/team-agent/src/mcp_server/wire.rs +2 -223
- package/crates/team-agent/src/messaging/leader_receiver.rs +1 -1
- package/crates/team-agent/src/messaging/mod.rs +2 -2
- package/crates/team-agent/src/messaging/results.rs +11 -42
- package/crates/team-agent/src/messaging/scheduler.rs +2 -205
- package/crates/team-agent/src/messaging/tests/basic.rs +0 -31
- package/crates/team-agent/src/messaging/tests/main_preserved.rs +0 -73
- package/crates/team-agent/src/messaging/tests/runtime.rs +0 -26
- package/crates/team-agent/src/messaging/types.rs +0 -20
- package/crates/team-agent/src/model/spec.rs +14 -18
- package/crates/team-agent/src/provider/mod.rs +1 -0
- package/crates/team-agent/src/provider/model_catalog.rs +650 -0
- package/crates/team-agent/src/state/repository/tests.rs +1 -16
- package/crates/team-agent/src/state/repository.rs +1 -33
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +5 -5
- package/skills/team-agent/command-coverage.json +7 -0
- package/skills/team-agent/references/team-agent-operator.md +24 -116
- package/skills/team-agent/references/team-in-team.md +4 -19
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +0 -484
|
@@ -44,48 +44,38 @@ pub struct IgnoredTeamField {
|
|
|
44
44
|
pub value: String,
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
///
|
|
48
|
-
///
|
|
49
|
-
/// Reads `path` (UTF-8). If the text does not start with `"---\n"`, returns
|
|
50
|
-
/// `({}, full_text)`. Otherwise splits on the first `"\n---"` after byte 4:
|
|
51
|
-
/// unterminated (no closing marker) → `ValidationError "{path}: unterminated
|
|
52
|
-
/// front matter"`; the front-matter block is parsed via `simple_yaml.loads`
|
|
53
|
-
/// (empty block → `{}`); a non-dict block → `ValidationError "{path}: front
|
|
54
|
-
/// matter must be a YAML object"`. The body is everything after the closing
|
|
55
|
-
/// marker, `lstrip("\n")` (leading NEWLINES only — not other whitespace).
|
|
47
|
+
/// Read optional YAML front matter; malformed or non-object headers are plain text.
|
|
56
48
|
pub fn read_front_matter(path: &Path) -> Result<(Value, String), ModelError> {
|
|
57
49
|
let text = fs::read_to_string(path)
|
|
58
50
|
.map_err(|e| ModelError::Runtime(format!("{}: {e}", path.display())))?;
|
|
59
51
|
let text = text.replace("\r\n", "\n").replace('\r', "\n");
|
|
52
|
+
let empty_meta = || Value::Map(Vec::new());
|
|
60
53
|
let Some(rest) = text.strip_prefix("---\n") else {
|
|
61
|
-
return Ok((
|
|
54
|
+
return Ok((empty_meta(), text));
|
|
62
55
|
};
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
))
|
|
56
|
+
|
|
57
|
+
let mut offset = 0;
|
|
58
|
+
let mut close = None;
|
|
59
|
+
for line in rest.split_inclusive('\n') {
|
|
60
|
+
if line.strip_suffix('\n').unwrap_or(line) == "---" {
|
|
61
|
+
close = Some(offset);
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
offset += line.len();
|
|
65
|
+
}
|
|
66
|
+
let Some(close) = close else {
|
|
67
|
+
return Ok((empty_meta(), text));
|
|
68
68
|
};
|
|
69
|
-
let raw_meta = rest
|
|
70
|
-
ModelError::Validation(format!("{}: unterminated front matter", path.display()))
|
|
71
|
-
})?;
|
|
72
|
-
let after_meta = rest.get(close..).ok_or_else(|| {
|
|
73
|
-
ModelError::Validation(format!("{}: unterminated front matter", path.display()))
|
|
74
|
-
})?;
|
|
75
|
-
let after_marker = after_meta.strip_prefix("\n---").ok_or_else(|| {
|
|
76
|
-
ModelError::Validation(format!("{}: unterminated front matter", path.display()))
|
|
77
|
-
})?;
|
|
69
|
+
let raw_meta = &rest[..close];
|
|
78
70
|
let meta = if raw_meta.trim().is_empty() {
|
|
79
|
-
|
|
71
|
+
empty_meta()
|
|
80
72
|
} else {
|
|
81
|
-
yaml::loads(raw_meta)
|
|
73
|
+
match yaml::loads(raw_meta) {
|
|
74
|
+
Ok(meta) if meta.is_map() => meta,
|
|
75
|
+
_ => return Ok((empty_meta(), text)),
|
|
76
|
+
}
|
|
82
77
|
};
|
|
83
|
-
|
|
84
|
-
return Err(ModelError::Validation(format!(
|
|
85
|
-
"{}: front matter must be a YAML object",
|
|
86
|
-
path.display()
|
|
87
|
-
)));
|
|
88
|
-
}
|
|
78
|
+
let after_marker = &rest[close + 3..];
|
|
89
79
|
Ok((meta, after_marker.trim_start_matches('\n').to_string()))
|
|
90
80
|
}
|
|
91
81
|
|
|
@@ -161,7 +151,7 @@ pub fn compile_team(team_dir: &Path) -> Result<Value, ModelError> {
|
|
|
161
151
|
let workspace = paths::team_workspace(team_dir)?;
|
|
162
152
|
let workspace_s = workspace.display().to_string();
|
|
163
153
|
let team_name =
|
|
164
|
-
string_field(&team_meta, "name").unwrap_or_else(||
|
|
154
|
+
string_field(&team_meta, "name").unwrap_or_else(|| workspace_dir_name(&workspace));
|
|
165
155
|
let objective = string_field(&team_meta, "objective")
|
|
166
156
|
.or_else(|| non_empty_trimmed(&team_body))
|
|
167
157
|
.unwrap_or_else(|| "Team Agent document-driven team.".to_string());
|
|
@@ -487,9 +477,23 @@ fn compile_role_agent_with_mode(
|
|
|
487
477
|
let (meta, body) = read_front_matter(role_path)?;
|
|
488
478
|
let communication_mode =
|
|
489
479
|
communication_mode_field(&meta, role_path)?.unwrap_or(team_communication_mode);
|
|
490
|
-
let id =
|
|
491
|
-
|
|
492
|
-
|
|
480
|
+
let id = string_field(&meta, "agent_id")
|
|
481
|
+
.or_else(|| string_field(&meta, "name"))
|
|
482
|
+
.filter(|id| !id.trim().is_empty())
|
|
483
|
+
.or_else(|| {
|
|
484
|
+
role_path
|
|
485
|
+
.file_stem()
|
|
486
|
+
.and_then(|stem| stem.to_str())
|
|
487
|
+
.filter(|stem| !stem.is_empty())
|
|
488
|
+
.map(ToString::to_string)
|
|
489
|
+
})
|
|
490
|
+
.unwrap_or_else(|| "worker".to_string());
|
|
491
|
+
let role = string_field(&meta, "role")
|
|
492
|
+
.filter(|role| !role.trim().is_empty())
|
|
493
|
+
.unwrap_or_else(|| id.clone());
|
|
494
|
+
let provider = string_field(&meta, "provider")
|
|
495
|
+
.filter(|provider| !provider.trim().is_empty())
|
|
496
|
+
.unwrap_or_else(|| "pi".to_string());
|
|
493
497
|
require_explicit_grok_role_model(&meta, role_path, &provider)?;
|
|
494
498
|
require_explicit_cursor_role_model(&meta, role_path, &provider)?;
|
|
495
499
|
validate_pi_role_fields(&meta, role_path, &provider)?;
|
|
@@ -498,7 +502,7 @@ fn compile_role_agent_with_mode(
|
|
|
498
502
|
string_field(&meta, "model")
|
|
499
503
|
.filter(|value| !value.trim().is_empty())
|
|
500
504
|
.map(Value::Str)
|
|
501
|
-
.
|
|
505
|
+
.unwrap_or_else(|| Value::Str("openai-codex/gpt-6-luna".to_string()))
|
|
502
506
|
} else {
|
|
503
507
|
resolve_model(&meta, team_meta, &provider)
|
|
504
508
|
};
|
|
@@ -532,11 +536,9 @@ fn compile_role_agent_with_mode(
|
|
|
532
536
|
("file", Value::Null),
|
|
533
537
|
]),
|
|
534
538
|
),
|
|
535
|
-
// 0.5.66 bypass 单源:compiler 透传角色 md 的 `dangerously_skip_permissions`
|
|
536
|
-
// (必填 bool,spec 校验保证存在)。取代旧"恒发 permission_mode: restricted"。
|
|
537
539
|
(
|
|
538
540
|
"dangerously_skip_permissions",
|
|
539
|
-
Value::Bool(
|
|
541
|
+
Value::Bool(optional_dangerously_skip_permissions(&meta)),
|
|
540
542
|
),
|
|
541
543
|
(
|
|
542
544
|
"communication_mode",
|
|
@@ -575,6 +577,7 @@ fn compile_role_agent_with_mode(
|
|
|
575
577
|
})?;
|
|
576
578
|
Some(parsed)
|
|
577
579
|
}
|
|
580
|
+
_ if is_pi => Some(ProviderEffort::Max),
|
|
578
581
|
_ => None,
|
|
579
582
|
};
|
|
580
583
|
let team_effort = match string_field(team_meta, "provider_effort") {
|
|
@@ -671,15 +674,6 @@ fn string_field(meta: &Value, key: &str) -> Option<String> {
|
|
|
671
674
|
.map(ToString::to_string)
|
|
672
675
|
}
|
|
673
676
|
|
|
674
|
-
fn required_string(meta: &Value, path: &Path, key: &str) -> Result<String, ModelError> {
|
|
675
|
-
string_field(meta, key).ok_or_else(|| {
|
|
676
|
-
ModelError::Validation(format!(
|
|
677
|
-
"{}: missing front matter field {key}",
|
|
678
|
-
path.display()
|
|
679
|
-
))
|
|
680
|
-
})
|
|
681
|
-
}
|
|
682
|
-
|
|
683
677
|
/// 缺 model 时框架会填内建默认(grok 上是 grok-4),席位因此静默拿到
|
|
684
678
|
/// 与角色文件无关的模型和上下文窗口,而 argv 看起来完全正常。
|
|
685
679
|
/// 角色文件必须自己写死;内建默认、team 级默认、CLI 全局默认都是隐式来源。
|
|
@@ -749,19 +743,10 @@ fn validate_pi_role_fields(meta: &Value, path: &Path, provider: &str) -> Result<
|
|
|
749
743
|
Ok(())
|
|
750
744
|
}
|
|
751
745
|
|
|
752
|
-
|
|
753
|
-
/// 缺 = 编译失败(消息含硬串);非 bool = 同 fail-loud。
|
|
754
|
-
fn required_dangerously_skip_permissions(meta: &Value, path: &Path) -> Result<bool, ModelError> {
|
|
746
|
+
fn optional_dangerously_skip_permissions(meta: &Value) -> bool {
|
|
755
747
|
match meta.get("dangerously_skip_permissions") {
|
|
756
|
-
Some(Value::Bool(value)) =>
|
|
757
|
-
|
|
758
|
-
"{}: front matter field dangerously_skip_permissions must be a boolean. This field must be declared explicitly; it controls whether the agent launches with permission prompts bypassed.",
|
|
759
|
-
path.display()
|
|
760
|
-
))),
|
|
761
|
-
None => Err(ModelError::Validation(format!(
|
|
762
|
-
"{}: missing front matter field dangerously_skip_permissions. This field must be declared explicitly; it controls whether the agent launches with permission prompts bypassed.",
|
|
763
|
-
path.display()
|
|
764
|
-
))),
|
|
748
|
+
Some(Value::Bool(value)) => *value,
|
|
749
|
+
_ => true,
|
|
765
750
|
}
|
|
766
751
|
}
|
|
767
752
|
|
|
@@ -883,10 +868,9 @@ fn non_empty_trimmed(text: &str) -> Option<String> {
|
|
|
883
868
|
}
|
|
884
869
|
}
|
|
885
870
|
|
|
886
|
-
fn
|
|
887
|
-
|
|
888
|
-
.
|
|
889
|
-
.and_then(Path::file_name)
|
|
871
|
+
fn workspace_dir_name(workspace: &Path) -> String {
|
|
872
|
+
workspace
|
|
873
|
+
.file_name()
|
|
890
874
|
.and_then(|s| s.to_str())
|
|
891
875
|
.filter(|s| !s.is_empty())
|
|
892
876
|
.unwrap_or("team")
|
|
@@ -105,12 +105,7 @@ fn s3_process_worker() {
|
|
|
105
105
|
assert_eq!(line.trim(), "commit");
|
|
106
106
|
});
|
|
107
107
|
}
|
|
108
|
-
let tools = crate::mcp_server::TeamOrchestratorTools::with_identity(
|
|
109
|
-
&dir, Some(crate::model::ids::AgentId::new("leader")), team.map(crate::model::ids::TeamKey::new),
|
|
110
|
-
);
|
|
111
108
|
match action.as_str() {
|
|
112
|
-
"assign" => { tools.assign_task(&payload, Some("bounded contract")).unwrap(); }
|
|
113
|
-
"note" => { println!("S3 note={:?}", tools.update_state(payload.as_str().unwrap()).unwrap()); }
|
|
114
109
|
"collect" => {
|
|
115
110
|
let output = crate::messaging::results::collect_for_team(&dir, Some(&dir.join("result.json")), false, team).unwrap();
|
|
116
111
|
assert_eq!(output["ok"], true);
|
|
@@ -152,10 +147,7 @@ fn s3_tick_preserves_tasks_notes_and_siblings() {
|
|
|
152
147
|
seed(dir, false);
|
|
153
148
|
let hook: SaveHook = Box::new(|ws, observed| {
|
|
154
149
|
assert_eq!(observed["agents"]["w1"]["worker_state"], "DEAD", "tick must produce a real observation delta");
|
|
155
|
-
write(ws.as_path(), "assign", Some("T1"), task("new-task"));
|
|
156
|
-
write(ws.as_path(), "note", Some("T1"), json!("concurrent note"));
|
|
157
150
|
write(ws.as_path(), "collect", Some("T1"), Value::Null);
|
|
158
|
-
write(ws.as_path(), "note", Some("T2"), json!("sibling note"));
|
|
159
151
|
write(ws.as_path(), "sibling", None, Value::Null);
|
|
160
152
|
let latest = disk(ws.as_path());
|
|
161
153
|
assert_eq!(latest["teams"]["T1"]["agents"]["w1"]["pane_id"], observed["agents"]["w1"]["pane_id"]);
|
|
@@ -166,11 +158,11 @@ fn s3_tick_preserves_tasks_notes_and_siblings() {
|
|
|
166
158
|
assert!(report.ok, "tick must save its observation: {report:?}");
|
|
167
159
|
let state = disk(dir);
|
|
168
160
|
let team = &state["teams"]["T1"];
|
|
161
|
+
assert_eq!(team["tasks"][0]["id"], "original");
|
|
169
162
|
assert_eq!(team["tasks"][0]["accepted_result_id"], "result-original");
|
|
170
|
-
|
|
171
|
-
assert_eq!(team["notes"], json!(["original note", "concurrent note"]));
|
|
163
|
+
assert_eq!(team["notes"], json!(["original note"]));
|
|
172
164
|
assert_eq!(team["agents"]["w1"]["worker_state"], "DEAD");
|
|
173
|
-
assert_eq!(state["teams"]["T2"]["notes"], json!(["original note"
|
|
165
|
+
assert_eq!(state["teams"]["T2"]["notes"], json!(["original note"]));
|
|
174
166
|
assert_eq!(state["teams"]["T3"]["notes"], json!(["third"]));
|
|
175
167
|
assert_eq!(state["tasks"], team["tasks"]);
|
|
176
168
|
assert_eq!(StateRepository::new(dir).load_team(Some("T1")).unwrap()["tasks"], team["tasks"]);
|
|
@@ -179,50 +171,26 @@ fn s3_tick_preserves_tasks_notes_and_siblings() {
|
|
|
179
171
|
}
|
|
180
172
|
|
|
181
173
|
#[test]
|
|
182
|
-
fn
|
|
183
|
-
isolated("
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
]
|
|
191
|
-
|
|
192
|
-
seed(&dir, false);
|
|
193
|
-
let waiting = start_writer(&dir, left.0, Some("T1"), left.1, true);
|
|
194
|
-
write(&dir, right.0, Some("T1"), right.1);
|
|
195
|
-
finish(waiting, true);
|
|
196
|
-
let state = disk(&dir);
|
|
197
|
-
let team = &state["teams"]["T1"];
|
|
198
|
-
if left.0 == "assign" { assert!(team["tasks"].as_array().unwrap().iter().any(|task| task["id"] == "left")); }
|
|
199
|
-
if right.0 == "assign" { assert!(team["tasks"].as_array().unwrap().iter().any(|task| task["id"] == "right")); }
|
|
200
|
-
if left.0 == "collect" { assert_eq!(team["tasks"][0]["accepted_result_id"], "result-original"); }
|
|
201
|
-
let notes = usize::from(left.0 == "note") + usize::from(right.0 == "note");
|
|
202
|
-
assert_eq!(team["notes"].as_array().unwrap().len(), 1 + notes);
|
|
203
|
-
if left.0 == "note" && right.0 == "assign" {
|
|
204
|
-
let markdown = std::fs::read_to_string(dir.join(".team/runtime/T1/team_state.md")).unwrap();
|
|
205
|
-
assert!(markdown.contains("right"), "note must render the committed task view: {markdown}");
|
|
206
|
-
}
|
|
207
|
-
if left.0 == "collect" {
|
|
208
|
-
write(&dir, "assign", Some("T1"), json!({"id": "original", "assignee": "w1", "title": "edited"}));
|
|
209
|
-
assert_eq!(disk(&dir)["teams"]["T1"]["tasks"][0]["accepted_result_id"], "result-original");
|
|
210
|
-
}
|
|
211
|
-
println!("S3 pair {i} canonical={team}");
|
|
212
|
-
}
|
|
174
|
+
fn s3_result_collection_commit_preserves_concurrent_sibling() {
|
|
175
|
+
isolated("s3_result_collection_commit_preserves_concurrent_sibling", |dir| {
|
|
176
|
+
seed(dir, false);
|
|
177
|
+
let waiting = start_writer(&dir, "collect", Some("T1"), Value::Null, true);
|
|
178
|
+
write(&dir, "sibling", None, Value::Null);
|
|
179
|
+
finish(waiting, true);
|
|
180
|
+
let state = disk(&dir);
|
|
181
|
+
assert_eq!(state["teams"]["T1"]["tasks"][0]["accepted_result_id"], "result-original");
|
|
182
|
+
assert_eq!(state["teams"]["T3"]["notes"], json!(["third"]));
|
|
183
|
+
assert_eq!(state["teams"]["T2"]["notes"], json!(["original note"]));
|
|
213
184
|
});
|
|
214
185
|
}
|
|
215
186
|
|
|
216
187
|
#[test]
|
|
217
|
-
fn
|
|
218
|
-
isolated("
|
|
188
|
+
fn s3_legacy_result_collection_preserves_root_shape() {
|
|
189
|
+
isolated("s3_legacy_result_collection_preserves_root_shape", |dir| {
|
|
219
190
|
seed(dir, true);
|
|
220
|
-
|
|
221
|
-
write(dir, "note", None, json!("legacy note"));
|
|
222
|
-
finish(waiting, true);
|
|
191
|
+
write(dir, "collect", None, Value::Null);
|
|
223
192
|
let state = disk(dir);
|
|
224
193
|
assert_eq!(state["tasks"][0]["accepted_result_id"], "result-original");
|
|
225
|
-
assert_eq!(state["notes"], json!(["original note", "legacy note"]));
|
|
226
194
|
assert!(state.get("teams").is_none(), "legacy single Team shape remains valid");
|
|
227
195
|
});
|
|
228
196
|
}
|
|
@@ -304,20 +304,7 @@ fn leader_start_plan_with_ambient_authority(
|
|
|
304
304
|
identity.team_id.as_str(),
|
|
305
305
|
);
|
|
306
306
|
let prompt = crate::lifecycle::worker_command_context::compile_pi_leader_system_prompt();
|
|
307
|
-
let team_mcp_tools = [
|
|
308
|
-
"assign_task",
|
|
309
|
-
"send_message",
|
|
310
|
-
"update_state",
|
|
311
|
-
"get_team_status",
|
|
312
|
-
"stop_agent",
|
|
313
|
-
"reset_agent",
|
|
314
|
-
"add_agent",
|
|
315
|
-
"clone_agent",
|
|
316
|
-
"fork_agent",
|
|
317
|
-
"request_human",
|
|
318
|
-
"stuck_list",
|
|
319
|
-
"stuck_cancel",
|
|
320
|
-
];
|
|
307
|
+
let team_mcp_tools = ["send_message", "report_result", "get_team_status"];
|
|
321
308
|
crate::lifecycle::launch::pi_mcp::materialize_pi_plan(
|
|
322
309
|
crate::lifecycle::launch::pi_mcp::PiMaterializeRequest {
|
|
323
310
|
workspace,
|
|
@@ -15,14 +15,13 @@
|
|
|
15
15
|
|
|
16
16
|
use std::collections::BTreeSet;
|
|
17
17
|
use std::fs::OpenOptions;
|
|
18
|
-
use std::io::
|
|
18
|
+
use std::io::Write;
|
|
19
19
|
#[cfg(test)]
|
|
20
20
|
use std::cell::RefCell;
|
|
21
21
|
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
|
22
22
|
use std::path::{Path, PathBuf};
|
|
23
|
-
use std::process::
|
|
24
|
-
use std::
|
|
25
|
-
use std::time::{Duration, Instant};
|
|
23
|
+
use std::process::Command;
|
|
24
|
+
use std::time::Duration;
|
|
26
25
|
|
|
27
26
|
use sha2::{Digest, Sha256};
|
|
28
27
|
|
|
@@ -132,62 +131,9 @@ pub(crate) fn new_pi_session_id() -> SessionId {
|
|
|
132
131
|
/// errors: 非 UTF-8、header/row 异常、重复或空 catalog 时返回 ProviderError
|
|
133
132
|
/// ---
|
|
134
133
|
pub(crate) fn parse_pi_list_models_table(bytes: &[u8]) -> Result<Vec<String>, ProviderError> {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
let mut lines = text.lines();
|
|
139
|
-
let header = lines
|
|
140
|
-
.next()
|
|
141
|
-
.ok_or_else(|| ProviderError::Command("Pi model catalog is empty".to_string()))?;
|
|
142
|
-
let header_columns = header.split_whitespace().collect::<Vec<_>>();
|
|
143
|
-
if header_columns.get(0) != Some(&"provider") || header_columns.get(1) != Some(&"model") {
|
|
144
|
-
return Err(ProviderError::Command(
|
|
145
|
-
"Pi model catalog has an unexpected header".to_string(),
|
|
146
|
-
));
|
|
147
|
-
}
|
|
148
|
-
let mut seen = BTreeSet::new();
|
|
149
|
-
let mut models = Vec::new();
|
|
150
|
-
for (index, line) in lines.enumerate() {
|
|
151
|
-
if line.trim().is_empty() {
|
|
152
|
-
continue;
|
|
153
|
-
}
|
|
154
|
-
let malformed = || {
|
|
155
|
-
ProviderError::Command(format!(
|
|
156
|
-
"Pi model catalog row {} is malformed",
|
|
157
|
-
index + 2
|
|
158
|
-
))
|
|
159
|
-
};
|
|
160
|
-
let (provider, model) = line
|
|
161
|
-
.trim()
|
|
162
|
-
.split_once(char::is_whitespace)
|
|
163
|
-
.ok_or_else(malformed)?;
|
|
164
|
-
let mut model = model.trim();
|
|
165
|
-
// Pi prints the raw model id, which may contain spaces. Only the
|
|
166
|
-
// capability columns following it are single whitespace-free values.
|
|
167
|
-
for _ in 2..header_columns.len() {
|
|
168
|
-
model = model
|
|
169
|
-
.rsplit_once(char::is_whitespace)
|
|
170
|
-
.ok_or_else(malformed)?
|
|
171
|
-
.0
|
|
172
|
-
.trim_end();
|
|
173
|
-
}
|
|
174
|
-
if model.is_empty() {
|
|
175
|
-
return Err(malformed());
|
|
176
|
-
}
|
|
177
|
-
let exact = format!("{provider}/{model}");
|
|
178
|
-
if !seen.insert(exact.clone()) {
|
|
179
|
-
return Err(ProviderError::Command(format!(
|
|
180
|
-
"Pi model catalog contains duplicate exact id {exact:?}"
|
|
181
|
-
)));
|
|
182
|
-
}
|
|
183
|
-
models.push(exact);
|
|
184
|
-
}
|
|
185
|
-
if models.is_empty() {
|
|
186
|
-
return Err(ProviderError::Command(
|
|
187
|
-
"Pi model catalog contains no models".to_string(),
|
|
188
|
-
));
|
|
189
|
-
}
|
|
190
|
-
Ok(models)
|
|
134
|
+
crate::provider::model_catalog::parse_pi_catalog(bytes)
|
|
135
|
+
.map(|records| records.into_iter().map(|record| record.id).collect())
|
|
136
|
+
.map_err(|error| ProviderError::Command(error.pi_legacy_message()))
|
|
191
137
|
}
|
|
192
138
|
|
|
193
139
|
/// ---
|
|
@@ -658,43 +604,6 @@ fn observe_pi_catalog_test(f: impl FnOnce(&mut PiCatalogTestContext)) {
|
|
|
658
604
|
});
|
|
659
605
|
}
|
|
660
606
|
|
|
661
|
-
fn spawn_catalog_reader(
|
|
662
|
-
stdout: std::process::ChildStdout,
|
|
663
|
-
max_bytes: u64,
|
|
664
|
-
) -> mpsc::Receiver<(std::io::Result<usize>, Vec<u8>)> {
|
|
665
|
-
let (sender, receiver) = mpsc::channel();
|
|
666
|
-
std::thread::spawn(move || {
|
|
667
|
-
let mut bytes = Vec::new();
|
|
668
|
-
let result = stdout.take(max_bytes + 1).read_to_end(&mut bytes);
|
|
669
|
-
let _ = sender.send((result, bytes));
|
|
670
|
-
});
|
|
671
|
-
receiver
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
fn finish_catalog_reader(
|
|
675
|
-
receiver: mpsc::Receiver<(std::io::Result<usize>, Vec<u8>)>,
|
|
676
|
-
status: std::process::ExitStatus,
|
|
677
|
-
timeout: Duration,
|
|
678
|
-
max_bytes: u64,
|
|
679
|
-
) -> Result<Vec<u8>, String> {
|
|
680
|
-
let (result, bytes) = match receiver.recv_timeout(timeout) {
|
|
681
|
-
Ok(value) => value,
|
|
682
|
-
Err(_) => {
|
|
683
|
-
#[cfg(test)]
|
|
684
|
-
observe_pi_catalog_test(|context| context.observation.reader_timeout = true);
|
|
685
|
-
return Err("Pi model catalog command timed out".to_string());
|
|
686
|
-
}
|
|
687
|
-
};
|
|
688
|
-
if !status.success() {
|
|
689
|
-
return Err("Pi model catalog command failed".into());
|
|
690
|
-
}
|
|
691
|
-
result.map_err(|_| "Pi model catalog could not be read".to_string())?;
|
|
692
|
-
if bytes.len() as u64 > max_bytes {
|
|
693
|
-
return Err("Pi model catalog exceeds the bounded output limit".into());
|
|
694
|
-
}
|
|
695
|
-
Ok(bytes)
|
|
696
|
-
}
|
|
697
|
-
|
|
698
607
|
#[cfg(test)]
|
|
699
608
|
pub(crate) fn run_pi_catalog_after_parent_exit_for_test(
|
|
700
609
|
stdout: std::process::ChildStdout,
|
|
@@ -708,86 +617,53 @@ pub(crate) fn run_pi_catalog_after_parent_exit_for_test(
|
|
|
708
617
|
context.observation.parent_exit_success = Some(status.success());
|
|
709
618
|
context.observation.parent_exit_code = status.code();
|
|
710
619
|
});
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
status,
|
|
714
|
-
timeout,
|
|
715
|
-
max_bytes,
|
|
620
|
+
crate::provider::model_catalog::finish_stdout_after_exit(
|
|
621
|
+
stdout, status, timeout, max_bytes, &mut PiCatalogObserver,
|
|
716
622
|
)
|
|
623
|
+
.map_err(|error| error.pi_legacy_message())
|
|
717
624
|
}
|
|
718
625
|
|
|
719
|
-
///
|
|
720
|
-
/// does not resolve adapters or scan wrapper chains; callers use it only for
|
|
721
|
-
/// rejecting an unqualified role model before lifecycle mutation.
|
|
626
|
+
/// Shared bounded native-command runner; launch preflight keeps its exact model gate.
|
|
722
627
|
pub(crate) fn run_pi_catalog(
|
|
723
628
|
executable: &Path,
|
|
724
629
|
timeout: Duration,
|
|
725
630
|
max_bytes: u64,
|
|
726
631
|
) -> Result<Vec<u8>, String> {
|
|
727
|
-
let mut
|
|
728
|
-
command.arg("--list-models");
|
|
632
|
+
let mut extra_env = Vec::new();
|
|
729
633
|
#[cfg(test)]
|
|
730
634
|
observe_pi_catalog_test(|context| {
|
|
731
635
|
context.observation.spawn_count += 1;
|
|
732
636
|
context.observation.argv.push("--list-models".to_string());
|
|
733
|
-
|
|
637
|
+
extra_env.push(("TEAM_AGENT_MODELS_TIMEOUT_RECEIPT".into(), context.receipt.as_os_str().into()));
|
|
734
638
|
if let Some(mode) = context.helper_mode.as_deref() {
|
|
735
|
-
|
|
639
|
+
extra_env.push(("TEAM_AGENT_MODELS_TIMEOUT_MODE".into(), mode.into()));
|
|
736
640
|
}
|
|
737
641
|
});
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
"run_pi_catalog spawn failed kind={:?} raw_os_error={:?}",
|
|
748
|
-
error.kind(),
|
|
749
|
-
error.raw_os_error()
|
|
750
|
-
);
|
|
751
|
-
}
|
|
752
|
-
let _ = error;
|
|
753
|
-
"Pi executable is unavailable on PATH".to_string()
|
|
754
|
-
})?;
|
|
642
|
+
crate::provider::model_catalog::run_command(
|
|
643
|
+
executable, &["--list-models"], None, None, &[], &extra_env, timeout, max_bytes,
|
|
644
|
+
&mut PiCatalogObserver,
|
|
645
|
+
)
|
|
646
|
+
.map_err(|error| error.pi_legacy_message())
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
struct PiCatalogObserver;
|
|
650
|
+
impl crate::provider::model_catalog::CatalogObserver for PiCatalogObserver {
|
|
755
651
|
#[cfg(test)]
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
});
|
|
772
|
-
return finish_catalog_reader(
|
|
773
|
-
receiver,
|
|
774
|
-
status,
|
|
775
|
-
timeout.saturating_sub(start.elapsed()),
|
|
776
|
-
max_bytes,
|
|
777
|
-
);
|
|
778
|
-
}
|
|
779
|
-
Ok(None) if start.elapsed() < timeout => std::thread::sleep(Duration::from_millis(20)),
|
|
780
|
-
Ok(None) => {
|
|
781
|
-
let _ = child.kill();
|
|
782
|
-
let _ = child.wait();
|
|
783
|
-
return Err("Pi model catalog command timed out".into());
|
|
784
|
-
}
|
|
785
|
-
Err(_) => {
|
|
786
|
-
let _ = child.kill();
|
|
787
|
-
let _ = child.wait();
|
|
788
|
-
return Err("Pi model catalog command could not be observed".into());
|
|
789
|
-
}
|
|
790
|
-
}
|
|
652
|
+
fn spawned(&mut self, pid: u32) {
|
|
653
|
+
observe_pi_catalog_test(|context| context.observation.parent_pid = Some(pid));
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
#[cfg(test)]
|
|
657
|
+
fn exited(&mut self, status: &std::process::ExitStatus) {
|
|
658
|
+
observe_pi_catalog_test(|context| {
|
|
659
|
+
context.observation.parent_exit_success = Some(status.success());
|
|
660
|
+
context.observation.parent_exit_code = status.code();
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
#[cfg(test)]
|
|
665
|
+
fn output_timeout(&mut self) {
|
|
666
|
+
observe_pi_catalog_test(|context| context.observation.reader_timeout = true);
|
|
791
667
|
}
|
|
792
668
|
}
|
|
793
669
|
|
|
@@ -167,7 +167,7 @@ pub(super) fn spawn_agents(
|
|
|
167
167
|
model: command_model,
|
|
168
168
|
effort: agent_effort,
|
|
169
169
|
system_prompt: &system_prompt,
|
|
170
|
-
team_mcp_tools: &["send_message", "report_result"],
|
|
170
|
+
team_mcp_tools: &["send_message", "report_result", "get_team_status"],
|
|
171
171
|
mcp_config: &mcp_config,
|
|
172
172
|
session_scope: crate::lifecycle::launch::pi_mcp::PiSessionScope::Isolated,
|
|
173
173
|
},
|
|
@@ -397,7 +397,7 @@ pub(super) fn spawn_agent_window(
|
|
|
397
397
|
model: command_model,
|
|
398
398
|
effort: restart_effort,
|
|
399
399
|
system_prompt: &system_prompt,
|
|
400
|
-
team_mcp_tools: &["send_message", "report_result"],
|
|
400
|
+
team_mcp_tools: &["send_message", "report_result", "get_team_status"],
|
|
401
401
|
mcp_config: &mcp_config,
|
|
402
402
|
session_scope: crate::lifecycle::launch::pi_mcp::PiSessionScope::Isolated,
|
|
403
403
|
};
|
|
@@ -532,22 +532,6 @@ fn reset_stopped_true_still_rechecks_same_role_residue_before_start() {
|
|
|
532
532
|
);
|
|
533
533
|
}
|
|
534
534
|
|
|
535
|
-
#[test]
|
|
536
|
-
fn mcp_reset_output_exposes_capture_outcome_and_weak_reset_proof() {
|
|
537
|
-
let src = include_str!(concat!(
|
|
538
|
-
env!("CARGO_MANIFEST_DIR"),
|
|
539
|
-
"/src/mcp_server/lifecycle_tools/agent_ops.rs"
|
|
540
|
-
));
|
|
541
|
-
let reset = source_section(src, "pub(crate) fn reset_agent", "pub(crate) fn fork_agent");
|
|
542
|
-
let json_object = reset_success_json_object(reset);
|
|
543
|
-
let missing = ["\"capture_state\":", "\"reset_proof\":", "\"transcript_missing\"", "\"attribution_ambiguous\"", "\"weak\""]
|
|
544
|
-
.into_iter().filter(|needle| !json_object.contains(needle)).collect::<Vec<_>>();
|
|
545
|
-
assert!(
|
|
546
|
-
missing.is_empty(),
|
|
547
|
-
"P0 MCP reset: ok=true/status=reset must expose additive capture_state and reset_proof=weak inside the ResetAgentOutcome::Reset serde_json::json! object; missing={missing:?}; reset_json={json_object}"
|
|
548
|
-
);
|
|
549
|
-
}
|
|
550
|
-
|
|
551
535
|
#[test]
|
|
552
536
|
#[serial(env)]
|
|
553
537
|
fn abnormal_shell_pane_with_live_delivery_is_not_dead_and_dedupe_ignores_size() {
|
|
@@ -972,15 +956,3 @@ impl ProviderRegistry for RealAdapterRegistry {
|
|
|
972
956
|
get_adapter(provider)
|
|
973
957
|
}
|
|
974
958
|
}
|
|
975
|
-
|
|
976
|
-
fn source_section<'a>(src: &'a str, start: &str, end: &str) -> &'a str { let start = src.find(start).unwrap_or(0); let rest = &src[start..]; &rest[..rest.find(end).unwrap_or(rest.len())] }
|
|
977
|
-
|
|
978
|
-
fn reset_success_json_object(reset_src: &str) -> &str {
|
|
979
|
-
let success = source_section(reset_src, "ResetAgentOutcome::Reset", "ResetAgentOutcome::Refused");
|
|
980
|
-
let macro_start = success.find("serde_json::json!({").expect("reset success json object"); let object_start = macro_start + success[macro_start..].find('{').unwrap(); let mut depth = 0_i32;
|
|
981
|
-
for (offset, ch) in success[object_start..].char_indices() {
|
|
982
|
-
if ch == '{' { depth += 1; } if ch == '}' { depth -= 1; }
|
|
983
|
-
if depth == 0 { return &success[object_start..object_start + offset + 1]; }
|
|
984
|
-
}
|
|
985
|
-
panic!("unterminated reset success json object")
|
|
986
|
-
}
|
|
@@ -108,12 +108,13 @@ fn pi_role_ignores_tools_and_preserves_provider_defaults() {
|
|
|
108
108
|
.expect("Pi model and effort may use provider defaults");
|
|
109
109
|
assert_eq!(
|
|
110
110
|
compiled.agent.get("model"),
|
|
111
|
-
Some(&Value::
|
|
112
|
-
"omitted Pi model
|
|
111
|
+
Some(&Value::Str("openai-codex/gpt-6-luna".to_string())),
|
|
112
|
+
"omitted Pi model uses the Luna default rather than team metadata"
|
|
113
113
|
);
|
|
114
|
-
|
|
115
|
-
compiled.agent.get("effort")
|
|
116
|
-
"
|
|
114
|
+
assert_eq!(
|
|
115
|
+
compiled.agent.get("effort"),
|
|
116
|
+
Some(&Value::Str("max".to_string())),
|
|
117
|
+
"omitted Pi effort uses the global max default rather than team metadata"
|
|
117
118
|
);
|
|
118
119
|
|
|
119
120
|
let models = parse_pi_list_models_table(CATALOG).expect("catalog fixture");
|
|
@@ -160,10 +161,11 @@ fn pi_role_uses_standard_bypass_field_without_fabricating_argv() {
|
|
|
160
161
|
);
|
|
161
162
|
|
|
162
163
|
let missing_ack = valid_role_with("").replace("dangerously_skip_permissions: true\n", "");
|
|
163
|
-
let
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
"
|
|
164
|
+
let compiled = compile_pi_role(&missing_ack)
|
|
165
|
+
.expect("omitted bypass metadata defaults to true");
|
|
166
|
+
assert_eq!(
|
|
167
|
+
compiled.agent.get("dangerously_skip_permissions"),
|
|
168
|
+
Some(&Value::Bool(true))
|
|
167
169
|
);
|
|
168
170
|
}
|
|
169
171
|
|