@team-agent/installer 0.5.71 → 0.5.72
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/adapters.rs +107 -9
- package/crates/team-agent/src/cli/emit.rs +35 -5
- package/crates/team-agent/src/cli/mod.rs +78 -7
- package/crates/team-agent/src/cli/models.rs +443 -0
- package/crates/team-agent/src/cli/spec.rs +4 -1
- package/crates/team-agent/src/cli/types.rs +8 -0
- package/crates/team-agent/src/compiler.rs +157 -0
- package/crates/team-agent/src/leader/owner_bind.rs +21 -0
- package/crates/team-agent/src/leader/provider_attribution.rs +25 -0
- package/crates/team-agent/src/leader/registry.rs +25 -1
- package/crates/team-agent/src/leader/start.rs +33 -9
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +169 -10
- package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +76 -2
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +859 -12
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +168 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +209 -0
- package/crates/team-agent/src/lifecycle/types.rs +8 -0
- package/npm/check-skill-links.mjs +88 -0
- package/package.json +5 -4
- package/skills/team-agent/SKILL.md +15 -49
- package/skills/team-agent/command-coverage.json +8 -1
- package/skills/team-agent/references/team-agent-operator.md +665 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
//! Read-only Pi model catalog discovery for `team-agent models`.
|
|
2
|
+
use super::{CliError, CmdOutput, CmdResult, ExitCode, ModelsArgs};
|
|
3
|
+
use serde_json::{json, Value};
|
|
4
|
+
use std::path::Path;
|
|
5
|
+
use std::process::Command;
|
|
6
|
+
use std::time::Duration;
|
|
7
|
+
const CATALOG_TIMEOUT: Duration = Duration::from_secs(10);
|
|
8
|
+
const MAX_CATALOG_BYTES: u64 = 1024 * 1024;
|
|
9
|
+
|
|
10
|
+
pub fn cmd_models(args: &ModelsArgs) -> Result<CmdResult, CliError> {
|
|
11
|
+
cmd_models_with(args, Path::new("pi"), CATALOG_TIMEOUT, MAX_CATALOG_BYTES)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
fn cmd_models_with(
|
|
15
|
+
args: &ModelsArgs,
|
|
16
|
+
program: &Path,
|
|
17
|
+
timeout: Duration,
|
|
18
|
+
max_bytes: u64,
|
|
19
|
+
) -> Result<CmdResult, CliError> {
|
|
20
|
+
let bytes = match run_catalog(program, timeout, max_bytes) {
|
|
21
|
+
Ok(bytes) => bytes,
|
|
22
|
+
Err(message) => return Ok(failure(args, &message)),
|
|
23
|
+
};
|
|
24
|
+
let all = match crate::lifecycle::launch::pi_mcp::parse_pi_list_models_table(&bytes) {
|
|
25
|
+
Ok(models) => models,
|
|
26
|
+
Err(error) => return Ok(failure(args, &safe_catalog_error(error))),
|
|
27
|
+
};
|
|
28
|
+
let visible = args.search.as_deref().map_or_else(
|
|
29
|
+
|| all.clone(),
|
|
30
|
+
|search| {
|
|
31
|
+
all.iter()
|
|
32
|
+
.filter(|model| model.contains(search))
|
|
33
|
+
.cloned()
|
|
34
|
+
.collect::<Vec<_>>()
|
|
35
|
+
},
|
|
36
|
+
);
|
|
37
|
+
let current = current_role_model(&all);
|
|
38
|
+
let entries: Vec<Value> = visible
|
|
39
|
+
.iter()
|
|
40
|
+
.map(|model| {
|
|
41
|
+
json!({
|
|
42
|
+
"role_model": model,
|
|
43
|
+
"current": current.as_deref() == Some(model.as_str()),
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
.collect();
|
|
47
|
+
let value = json!({
|
|
48
|
+
"schema_version": "models.v1", "ok": true, "provider": "pi", "models": entries,
|
|
49
|
+
"auth": "ok", "auth_basis": "catalog_visibility", "current_role_model": current, "search": args.search,
|
|
50
|
+
});
|
|
51
|
+
if args.json {
|
|
52
|
+
let text = serde_json::to_string_pretty(&value)?;
|
|
53
|
+
Ok(CmdResult {
|
|
54
|
+
output: CmdOutput::Human(text),
|
|
55
|
+
exit: ExitCode::Ok,
|
|
56
|
+
as_json: false,
|
|
57
|
+
preserve_json_order: true,
|
|
58
|
+
})
|
|
59
|
+
} else {
|
|
60
|
+
let mut lines = vec!["models.v1 | Pi models (copyable role_model):".to_string()];
|
|
61
|
+
lines.extend(visible.iter().map(|model| {
|
|
62
|
+
format!(
|
|
63
|
+
"role_model: {model} current={}",
|
|
64
|
+
current.as_deref() == Some(model.as_str())
|
|
65
|
+
)
|
|
66
|
+
}));
|
|
67
|
+
lines.push(format!(
|
|
68
|
+
"auth: ok (catalog_visibility); {} model(s)",
|
|
69
|
+
visible.len()
|
|
70
|
+
));
|
|
71
|
+
if visible.is_empty() {
|
|
72
|
+
lines.push("No models matched --search.".to_string());
|
|
73
|
+
}
|
|
74
|
+
Ok(CmdResult::human(lines.join("\n")))
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
fn failure(args: &ModelsArgs, message: &str) -> CmdResult {
|
|
79
|
+
let value = json!({ "schema_version": "models.v1", "ok": false, "provider": "pi", "auth": "not_ready", "auth_basis": "catalog_visibility", "models": Value::Array(Vec::new()), "current_role_model": Value::Null, "error": message, "action": "install or repair the PATH-first `pi` executable, then retry `team-agent models --provider pi`" });
|
|
80
|
+
if args.json {
|
|
81
|
+
let text = serde_json::to_string_pretty(&value)
|
|
82
|
+
.unwrap_or_else(|_| "{\"schema_version\":\"models.v1\",\"ok\":false}".to_string());
|
|
83
|
+
CmdResult {
|
|
84
|
+
output: CmdOutput::Human(text),
|
|
85
|
+
exit: ExitCode::Error,
|
|
86
|
+
as_json: false,
|
|
87
|
+
preserve_json_order: true,
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
CmdResult { output: CmdOutput::Human(format!("models.v1 | error: {message}\naction: install or repair the PATH-first `pi` executable, then retry `team-agent models --provider pi`\nauth: not_ready (catalog_visibility)")), exit: ExitCode::Error, as_json: false, preserve_json_order: false }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
fn safe_catalog_error(error: crate::provider::ProviderError) -> String {
|
|
95
|
+
let text = error.to_string();
|
|
96
|
+
if text.contains("not UTF-8") {
|
|
97
|
+
"Pi model catalog is not valid UTF-8".into()
|
|
98
|
+
} else if text.contains("duplicate") {
|
|
99
|
+
"Pi model catalog contains duplicate model ids".into()
|
|
100
|
+
} else if text.contains("header") {
|
|
101
|
+
"Pi model catalog has an unexpected header".into()
|
|
102
|
+
} else if text.contains("row") {
|
|
103
|
+
"Pi model catalog contains a malformed row".into()
|
|
104
|
+
} else if text.contains("empty") || text.contains("no models") {
|
|
105
|
+
"Pi model catalog is empty".into()
|
|
106
|
+
} else {
|
|
107
|
+
"Pi model catalog is invalid".into()
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
fn current_role_model(models: &[String]) -> Option<String> {
|
|
111
|
+
current_role_model_from(
|
|
112
|
+
models,
|
|
113
|
+
std::env::var("PI_PROVIDER").ok().as_deref(),
|
|
114
|
+
std::env::var("PI_MODEL").ok().as_deref(),
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
fn current_role_model_from(
|
|
118
|
+
models: &[String],
|
|
119
|
+
provider: Option<&str>,
|
|
120
|
+
model: Option<&str>,
|
|
121
|
+
) -> Option<String> {
|
|
122
|
+
let exact = format!("{}/{}", provider?, model?);
|
|
123
|
+
models
|
|
124
|
+
.iter()
|
|
125
|
+
.any(|candidate| candidate == &exact)
|
|
126
|
+
.then_some(exact)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/// Injectable executable, deadline, and byte-limit boundary for deterministic tests.
|
|
130
|
+
fn run_catalog(program: &Path, timeout: Duration, max_bytes: u64) -> Result<Vec<u8>, String> {
|
|
131
|
+
crate::lifecycle::launch::pi_mcp::run_pi_catalog(program, timeout, max_bytes)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#[cfg(test)]
|
|
135
|
+
mod tests {
|
|
136
|
+
use super::*;
|
|
137
|
+
use std::time::Instant;
|
|
138
|
+
|
|
139
|
+
#[cfg(unix)]
|
|
140
|
+
#[test]
|
|
141
|
+
fn real_dispatcher_child_helper() {
|
|
142
|
+
if std::env::var_os("TEAM_AGENT_MODELS_CHILD").is_none() {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
let args: Vec<String> =
|
|
146
|
+
serde_json::from_str(&std::env::var("TEAM_AGENT_MODELS_ARGS").unwrap()).unwrap();
|
|
147
|
+
print!("__TEAM_AGENT_MODELS_CLI_OUTPUT_v1__\n");
|
|
148
|
+
use std::io::Write;
|
|
149
|
+
std::io::stdout().flush().unwrap();
|
|
150
|
+
let exit = crate::cli::emit::run(&args, Path::new("/tmp"));
|
|
151
|
+
std::process::exit(exit.code());
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#[cfg(unix)]
|
|
155
|
+
#[test]
|
|
156
|
+
fn real_dispatcher_isolated_success_json_human_and_no_match() {
|
|
157
|
+
let pi = fixture("printf 'provider model\\nopenai-codex gpt-5.6-sol\\n'");
|
|
158
|
+
let dir = pi.parent().unwrap().to_path_buf();
|
|
159
|
+
let run_child = |args: &[&str], model: &str| {
|
|
160
|
+
std::process::Command::new(std::env::current_exe().unwrap())
|
|
161
|
+
.args([
|
|
162
|
+
"--exact",
|
|
163
|
+
"cli::models::tests::real_dispatcher_child_helper",
|
|
164
|
+
"--nocapture",
|
|
165
|
+
])
|
|
166
|
+
.env("TEAM_AGENT_MODELS_CHILD", "1")
|
|
167
|
+
.env(
|
|
168
|
+
"TEAM_AGENT_MODELS_ARGS",
|
|
169
|
+
serde_json::to_string(args).unwrap(),
|
|
170
|
+
)
|
|
171
|
+
.env("PATH", &dir)
|
|
172
|
+
.env("PI_PROVIDER", "openai-codex")
|
|
173
|
+
.env("PI_MODEL", model)
|
|
174
|
+
.output()
|
|
175
|
+
.unwrap()
|
|
176
|
+
};
|
|
177
|
+
let json = run_child(&["models", "--provider", "pi", "--json"], "gpt-5.6-sol");
|
|
178
|
+
assert!(json.status.success());
|
|
179
|
+
let output = String::from_utf8(json.stdout).unwrap();
|
|
180
|
+
let cli_output = output
|
|
181
|
+
.split("__TEAM_AGENT_MODELS_CLI_OUTPUT_v1__\n")
|
|
182
|
+
.nth(1)
|
|
183
|
+
.unwrap();
|
|
184
|
+
let value: Value = serde_json::from_str(cli_output.trim()).unwrap();
|
|
185
|
+
assert_eq!(value["schema_version"], "models.v1");
|
|
186
|
+
assert_eq!(value["models"][0]["role_model"], "openai-codex/gpt-5.6-sol");
|
|
187
|
+
assert_eq!(value["models"][0]["current"], true);
|
|
188
|
+
let human = run_child(
|
|
189
|
+
&["models", "--provider", "pi", "--search", "absent"],
|
|
190
|
+
"missing",
|
|
191
|
+
);
|
|
192
|
+
assert!(human.status.success());
|
|
193
|
+
let human_output = String::from_utf8(human.stdout).unwrap();
|
|
194
|
+
let text = human_output
|
|
195
|
+
.split("__TEAM_AGENT_MODELS_CLI_OUTPUT_v1__\n")
|
|
196
|
+
.nth(1)
|
|
197
|
+
.unwrap();
|
|
198
|
+
assert!(text.contains("No models matched --search") && text.contains("auth: ok"));
|
|
199
|
+
let null = run_child(&["models", "--provider", "pi", "--json"], "missing");
|
|
200
|
+
let null_output = String::from_utf8(null.stdout).unwrap();
|
|
201
|
+
let null_cli = null_output
|
|
202
|
+
.split("__TEAM_AGENT_MODELS_CLI_OUTPUT_v1__\n")
|
|
203
|
+
.nth(1)
|
|
204
|
+
.unwrap();
|
|
205
|
+
let null_value: Value = serde_json::from_str(null_cli.trim()).unwrap();
|
|
206
|
+
assert!(null.status.success() && null_value["current_role_model"].is_null());
|
|
207
|
+
std::fs::write(&pi, "#!/bin/sh\nprintf 'sensitive-stderr' >&2\nexit 7\n").unwrap();
|
|
208
|
+
let failed = run_child(&["models", "--provider", "pi", "--json"], "missing");
|
|
209
|
+
assert!(!failed.status.success());
|
|
210
|
+
let failed_output = String::from_utf8(failed.stdout).unwrap();
|
|
211
|
+
let failed_cli = failed_output
|
|
212
|
+
.split("__TEAM_AGENT_MODELS_CLI_OUTPUT_v1__\n")
|
|
213
|
+
.nth(1)
|
|
214
|
+
.unwrap();
|
|
215
|
+
assert!(!failed_cli.contains("sensitive-stderr"));
|
|
216
|
+
assert_eq!(
|
|
217
|
+
std::fs::read_to_string(pi.with_extension("count"))
|
|
218
|
+
.unwrap()
|
|
219
|
+
.lines()
|
|
220
|
+
.count(),
|
|
221
|
+
3
|
|
222
|
+
);
|
|
223
|
+
cleanup_fixture(&pi);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
#[test]
|
|
227
|
+
fn current_model_is_deterministic() {
|
|
228
|
+
let models = vec!["openai-codex/gpt-5.6-sol".into()];
|
|
229
|
+
assert_eq!(
|
|
230
|
+
current_role_model_from(&models, Some("openai-codex"), Some("gpt-5.6-sol")),
|
|
231
|
+
Some("openai-codex/gpt-5.6-sol".into())
|
|
232
|
+
);
|
|
233
|
+
assert_eq!(
|
|
234
|
+
current_role_model_from(&models, Some("openai-codex"), Some("other")),
|
|
235
|
+
None
|
|
236
|
+
);
|
|
237
|
+
assert_eq!(
|
|
238
|
+
current_role_model_from(&models, None, Some("gpt-5.6-sol")),
|
|
239
|
+
None
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
#[cfg(unix)]
|
|
243
|
+
fn fixture(body: &str) -> std::path::PathBuf {
|
|
244
|
+
use std::os::unix::fs::PermissionsExt;
|
|
245
|
+
let root = std::env::current_exe()
|
|
246
|
+
.unwrap()
|
|
247
|
+
.parent()
|
|
248
|
+
.unwrap()
|
|
249
|
+
.join(format!(
|
|
250
|
+
"team-agent-pi-fixture-{}-{}",
|
|
251
|
+
std::process::id(),
|
|
252
|
+
std::time::SystemTime::now()
|
|
253
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
254
|
+
.unwrap()
|
|
255
|
+
.as_nanos()
|
|
256
|
+
));
|
|
257
|
+
std::fs::create_dir(&root).unwrap();
|
|
258
|
+
let path = root.join("pi");
|
|
259
|
+
std::fs::write(
|
|
260
|
+
&path,
|
|
261
|
+
format!("#!/bin/sh\nprintf '%s\\n' \"$#:$1\" >> \"$0.count\"\n{body}\n"),
|
|
262
|
+
)
|
|
263
|
+
.unwrap();
|
|
264
|
+
let mut permissions = std::fs::metadata(&path).unwrap().permissions();
|
|
265
|
+
permissions.set_mode(0o700);
|
|
266
|
+
std::fs::set_permissions(&path, permissions).unwrap();
|
|
267
|
+
path
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
#[cfg(unix)]
|
|
271
|
+
fn cleanup_fixture(path: &Path) {
|
|
272
|
+
let _ = std::fs::remove_dir_all(path.parent().unwrap());
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
#[cfg(unix)]
|
|
276
|
+
#[test]
|
|
277
|
+
fn runner_invokes_exact_argv_once_and_drains() {
|
|
278
|
+
let path = fixture(
|
|
279
|
+
"test \"$1\" = --list-models && echo provider model && echo openai-codex gpt-5.6-sol",
|
|
280
|
+
);
|
|
281
|
+
assert!(run_catalog(&path, Duration::from_secs(1), 1024)
|
|
282
|
+
.unwrap()
|
|
283
|
+
.starts_with(b"provider model"));
|
|
284
|
+
assert_eq!(
|
|
285
|
+
std::fs::read_to_string(path.with_extension("count")).unwrap(),
|
|
286
|
+
"1:--list-models\n"
|
|
287
|
+
);
|
|
288
|
+
cleanup_fixture(&path);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
#[cfg(unix)]
|
|
292
|
+
#[test]
|
|
293
|
+
fn runner_timeout_is_bounded_when_descendant_keeps_stdout() {
|
|
294
|
+
let path = fixture("sleep 2 & exit 0");
|
|
295
|
+
let started = Instant::now();
|
|
296
|
+
let result = run_catalog(&path, Duration::from_millis(40), 1024);
|
|
297
|
+
assert!(started.elapsed() < Duration::from_millis(500));
|
|
298
|
+
assert!(result.unwrap_err().contains("timed out"));
|
|
299
|
+
cleanup_fixture(&path);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
#[cfg(unix)]
|
|
303
|
+
#[test]
|
|
304
|
+
fn command_boundary_connects_runner_parser_search_and_contract() {
|
|
305
|
+
let path = fixture(
|
|
306
|
+
"printf 'provider model\\nopenai-codex gpt-5.6-sol\\nopenai-codex gpt-5.6-luna\\n'",
|
|
307
|
+
);
|
|
308
|
+
let args = ModelsArgs {
|
|
309
|
+
provider: "pi".into(),
|
|
310
|
+
search: Some("sol".into()),
|
|
311
|
+
json: true,
|
|
312
|
+
};
|
|
313
|
+
let result = cmd_models_with(&args, &path, Duration::from_secs(1), 1024).unwrap();
|
|
314
|
+
let CmdOutput::Human(text) = result.output else {
|
|
315
|
+
panic!("expected JSON projection")
|
|
316
|
+
};
|
|
317
|
+
let value: Value = serde_json::from_str(&text).unwrap();
|
|
318
|
+
assert_eq!(value["schema_version"], "models.v1");
|
|
319
|
+
assert_eq!(value["models"][0]["role_model"], "openai-codex/gpt-5.6-sol");
|
|
320
|
+
assert_eq!(value["models"][0]["current"], false);
|
|
321
|
+
assert_eq!(value["auth"], "ok");
|
|
322
|
+
assert_eq!(value["models"].as_array().unwrap().len(), 1);
|
|
323
|
+
let human = cmd_models_with(
|
|
324
|
+
&ModelsArgs {
|
|
325
|
+
provider: "pi".into(),
|
|
326
|
+
search: Some("absent".into()),
|
|
327
|
+
json: false,
|
|
328
|
+
},
|
|
329
|
+
&path,
|
|
330
|
+
Duration::from_secs(1),
|
|
331
|
+
1024,
|
|
332
|
+
)
|
|
333
|
+
.unwrap();
|
|
334
|
+
let CmdOutput::Human(text) = human.output else {
|
|
335
|
+
panic!("expected human projection")
|
|
336
|
+
};
|
|
337
|
+
assert!(text.contains("No models matched --search") && text.contains("auth: ok"));
|
|
338
|
+
let refusal = crate::cli::emit::__test_dispatch(
|
|
339
|
+
"models",
|
|
340
|
+
&["--provider".into(), "codex".into()],
|
|
341
|
+
Path::new("/tmp"),
|
|
342
|
+
);
|
|
343
|
+
match refusal {
|
|
344
|
+
Err(CliError::Usage(message)) => {
|
|
345
|
+
assert_eq!(message, "models supports only --provider pi, got \"codex\"")
|
|
346
|
+
}
|
|
347
|
+
other => panic!("expected provider refusal, got {other:?}"),
|
|
348
|
+
}
|
|
349
|
+
cleanup_fixture(&path);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
#[cfg(unix)]
|
|
353
|
+
#[test]
|
|
354
|
+
fn runner_fails_closed_for_nonzero_oversize_timeout_and_unavailable() {
|
|
355
|
+
let fail = fixture("echo sensitive-token >&2; exit 7");
|
|
356
|
+
assert_eq!(
|
|
357
|
+
run_catalog(&fail, Duration::from_secs(1), 1024).unwrap_err(),
|
|
358
|
+
"Pi model catalog command failed"
|
|
359
|
+
);
|
|
360
|
+
let failed = cmd_models_with(
|
|
361
|
+
&ModelsArgs {
|
|
362
|
+
provider: "pi".into(),
|
|
363
|
+
search: None,
|
|
364
|
+
json: false,
|
|
365
|
+
},
|
|
366
|
+
&fail,
|
|
367
|
+
Duration::from_secs(1),
|
|
368
|
+
1024,
|
|
369
|
+
)
|
|
370
|
+
.unwrap();
|
|
371
|
+
let CmdOutput::Human(text) = failed.output else {
|
|
372
|
+
panic!("expected failure projection")
|
|
373
|
+
};
|
|
374
|
+
assert_eq!(failed.exit, ExitCode::Error);
|
|
375
|
+
assert!(!text.contains("sensitive-token"));
|
|
376
|
+
let big = fixture("head -c 64 /dev/zero");
|
|
377
|
+
assert!(run_catalog(&big, Duration::from_secs(1), 8)
|
|
378
|
+
.unwrap_err()
|
|
379
|
+
.contains("bounded output"));
|
|
380
|
+
let slow = fixture("sleep 2");
|
|
381
|
+
assert!(run_catalog(&slow, Duration::from_millis(20), 1024)
|
|
382
|
+
.unwrap_err()
|
|
383
|
+
.contains("timed out"));
|
|
384
|
+
assert!(run_catalog(
|
|
385
|
+
std::path::Path::new("/does/not/exist"),
|
|
386
|
+
Duration::from_secs(1),
|
|
387
|
+
1024
|
|
388
|
+
)
|
|
389
|
+
.unwrap_err()
|
|
390
|
+
.contains("unavailable"));
|
|
391
|
+
for path in [fail, big, slow] {
|
|
392
|
+
cleanup_fixture(&path);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
#[test]
|
|
397
|
+
fn public_failure_and_help_contracts_are_versioned() {
|
|
398
|
+
let args = ModelsArgs {
|
|
399
|
+
provider: "pi".into(),
|
|
400
|
+
search: None,
|
|
401
|
+
json: true,
|
|
402
|
+
};
|
|
403
|
+
let result = failure(&args, "safe failure");
|
|
404
|
+
let CmdOutput::Human(text) = result.output else {
|
|
405
|
+
panic!("json contract must render")
|
|
406
|
+
};
|
|
407
|
+
let value: Value = serde_json::from_str(&text).unwrap();
|
|
408
|
+
assert_eq!(value["schema_version"], "models.v1");
|
|
409
|
+
assert_eq!(value["models"], Value::Array(vec![]));
|
|
410
|
+
assert_eq!(result.exit, ExitCode::Error);
|
|
411
|
+
assert!(crate::cli::emit::default_help().contains("models"));
|
|
412
|
+
assert_eq!(
|
|
413
|
+
crate::cli::run(&["models".into(), "--help".into()], Path::new("/tmp")),
|
|
414
|
+
ExitCode::Ok
|
|
415
|
+
);
|
|
416
|
+
assert_eq!(
|
|
417
|
+
crate::cli::run(
|
|
418
|
+
&["models".into(), "--provider".into(), "codex".into()],
|
|
419
|
+
Path::new("/tmp")
|
|
420
|
+
),
|
|
421
|
+
ExitCode::Error
|
|
422
|
+
);
|
|
423
|
+
assert_eq!(
|
|
424
|
+
crate::cli::spec::command_spec("models").unwrap().usage,
|
|
425
|
+
"usage: team-agent models --provider pi [--search TEXT] [--json]"
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
#[test]
|
|
430
|
+
fn parser_contract_fixtures() {
|
|
431
|
+
let parse =
|
|
432
|
+
|input: &[u8]| crate::lifecycle::launch::pi_mcp::parse_pi_list_models_table(input);
|
|
433
|
+
assert_eq!(
|
|
434
|
+
parse(b"provider model\r\nopenai-codex gpt-5.6-sol\r\n").unwrap(),
|
|
435
|
+
vec!["openai-codex/gpt-5.6-sol"]
|
|
436
|
+
);
|
|
437
|
+
assert!(parse(b"provider model\n").is_err());
|
|
438
|
+
assert!(parse(b"provider model\na b\na b\n").is_err());
|
|
439
|
+
assert!(parse(b"wrong model\na b\n").is_err());
|
|
440
|
+
assert!(parse(b"provider model\na\n").is_err());
|
|
441
|
+
assert!(parse(&[b'p', b'r', 0xff]).is_err());
|
|
442
|
+
}
|
|
443
|
+
}
|
|
@@ -85,6 +85,7 @@ pub(crate) enum DispatchKind {
|
|
|
85
85
|
E2e,
|
|
86
86
|
Peek,
|
|
87
87
|
Coordinator,
|
|
88
|
+
Models,
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
pub(crate) const ALL_DISPATCH_KINDS: &[DispatchKind] = &[
|
|
@@ -131,6 +132,7 @@ pub(crate) const ALL_DISPATCH_KINDS: &[DispatchKind] = &[
|
|
|
131
132
|
DispatchKind::E2e,
|
|
132
133
|
DispatchKind::Peek,
|
|
133
134
|
DispatchKind::Coordinator,
|
|
135
|
+
DispatchKind::Models,
|
|
134
136
|
];
|
|
135
137
|
|
|
136
138
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
@@ -162,8 +164,9 @@ pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
|
|
|
162
164
|
CommandSpec { name: "quick-start", tier: CommandTier::Core, category: CommandCategory::Start, kind: CommandKind::Dispatch(DispatchKind::QuickStart), summary: "start or attach a team from TEAM.md", usage: "usage: team-agent quick-start [TEAMDIR] [--workspace WORKSPACE] [--name NAME] [--team-id TEAM|--team TEAM] [--yes] [--no-display] [--backend tmux|conpty] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
|
|
163
165
|
CommandSpec { name: "send", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Send), summary: "persist a message for an in-team short name or fully-qualified logical recipient", usage: "usage: team-agent send TO MESSAGE... [--workspace WORKSPACE] [--team TEAM] [--mailbox] [--json]\nTO forms are co-equal: an in-team short name (for example `team-agent send reviewer \"Review\"`) or `<workspace>::<team>/<agent>`.", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: Some("next compatibility release"), action: Some("use positional logical TO and the returned message id"), governance: None },
|
|
164
166
|
CommandSpec { name: "status", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Status), summary: "show current team status", usage: "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
167
|
+
CommandSpec { name: "models", tier: CommandTier::Core, category: CommandCategory::Observe, kind: CommandKind::Dispatch(DispatchKind::Models), summary: "list exact provider model ids", usage: "usage: team-agent models --provider pi [--search TEXT] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
165
168
|
CommandSpec { name: "collect", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Collect), summary: "collect reported results", usage: "usage: team-agent collect [--workspace WORKSPACE] [--team TEAM] [--result-file FILE] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
166
|
-
CommandSpec { name: "results", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Results), summary: "read reported results for a case", usage: "usage: team-agent results --case CASE_ID [--workspace WORKSPACE] [--team TEAM] [--json]", default_help:
|
|
169
|
+
CommandSpec { name: "results", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Results), summary: "read reported results for a case", usage: "usage: team-agent results --case CASE_ID [--workspace WORKSPACE] [--team TEAM] [--json]", default_help: false, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
167
170
|
CommandSpec { name: "wait", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Wait), summary: "block until a task result is stored", usage: "usage: team-agent wait --task TASK [--workspace WORKSPACE] [--json]", default_help: false, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
168
171
|
CommandSpec { name: "restart", tier: CommandTier::Core, category: CommandCategory::TeamLifecycle, kind: CommandKind::Dispatch(DispatchKind::Restart), summary: "restart the selected team", usage: "usage: team-agent restart [WORKSPACE] [--team TEAM] [--allow-fresh] [--session-converge-deadline SECONDS] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
|
|
169
172
|
CommandSpec { name: "shutdown", tier: CommandTier::Core, category: CommandCategory::TeamLifecycle, kind: CommandKind::Dispatch(DispatchKind::Shutdown), summary: "stop the selected team", usage: "usage: team-agent shutdown [--workspace WORKSPACE] [--team TEAM] [--keep-logs] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
@@ -638,6 +638,14 @@ pub struct LeadersArgs {
|
|
|
638
638
|
pub json: bool,
|
|
639
639
|
}
|
|
640
640
|
|
|
641
|
+
/// Read-only Pi model catalog discovery.
|
|
642
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
643
|
+
pub struct ModelsArgs {
|
|
644
|
+
pub provider: String,
|
|
645
|
+
pub search: Option<String>,
|
|
646
|
+
pub json: bool,
|
|
647
|
+
}
|
|
648
|
+
|
|
641
649
|
/// `validate [spec=team.spec.yaml] --json`(`parser.py:120`)。
|
|
642
650
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
643
651
|
pub struct ValidateArgs {
|
|
@@ -366,6 +366,114 @@ pub struct CompiledRole {
|
|
|
366
366
|
pub agent: Value,
|
|
367
367
|
}
|
|
368
368
|
|
|
369
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
370
|
+
pub struct PiModelPreflightError {
|
|
371
|
+
pub requested: String,
|
|
372
|
+
pub candidates: Vec<String>,
|
|
373
|
+
pub action: String,
|
|
374
|
+
pub not_ready: bool,
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
impl std::fmt::Display for PiModelPreflightError {
|
|
378
|
+
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
379
|
+
write!(
|
|
380
|
+
formatter,
|
|
381
|
+
"Pi role model {:?} is not a qualified exact model",
|
|
382
|
+
self.requested
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/// Shared admission gate: omitted and qualified models pass; only an
|
|
388
|
+
/// unqualified present model causes one bounded catalog observation.
|
|
389
|
+
pub fn preflight_pi_role_model(meta: &Value) -> Result<(), PiModelPreflightError> {
|
|
390
|
+
preflight_pi_role_model_with(meta, |requested| {
|
|
391
|
+
crate::lifecycle::launch::pi_mcp::pi_model_candidates(requested).map_err(|_| ())
|
|
392
|
+
})
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
pub fn preflight_pi_role_model_with<F>(
|
|
396
|
+
meta: &Value,
|
|
397
|
+
mut discover: F,
|
|
398
|
+
) -> Result<(), PiModelPreflightError>
|
|
399
|
+
where
|
|
400
|
+
F: FnMut(&str) -> Result<Vec<String>, ()>,
|
|
401
|
+
{
|
|
402
|
+
if parse_canonical_provider(meta.get("provider").and_then(Value::as_str).unwrap_or(""))
|
|
403
|
+
!= Some(Provider::Pi)
|
|
404
|
+
{
|
|
405
|
+
return Ok(());
|
|
406
|
+
}
|
|
407
|
+
let Some(model) = string_field(meta, "model").filter(|value| !value.trim().is_empty()) else {
|
|
408
|
+
return Ok(());
|
|
409
|
+
};
|
|
410
|
+
let requested = model.trim().to_string();
|
|
411
|
+
if requested.split_once('/').is_some_and(|(provider, name)| {
|
|
412
|
+
!provider.is_empty() && !name.is_empty() && !requested.contains('*')
|
|
413
|
+
}) {
|
|
414
|
+
return Ok(());
|
|
415
|
+
}
|
|
416
|
+
let (candidates, not_ready) = match discover(&requested) {
|
|
417
|
+
Ok(candidates) => (candidates, false),
|
|
418
|
+
Err(()) => (Vec::new(), true),
|
|
419
|
+
};
|
|
420
|
+
let action = if candidates.is_empty() {
|
|
421
|
+
format!("run `team-agent models --provider pi --search {requested}`")
|
|
422
|
+
} else {
|
|
423
|
+
format!("copy a candidate into the role, or run `team-agent models --provider pi --search {requested}`")
|
|
424
|
+
};
|
|
425
|
+
Err(PiModelPreflightError {
|
|
426
|
+
requested,
|
|
427
|
+
candidates,
|
|
428
|
+
action,
|
|
429
|
+
not_ready,
|
|
430
|
+
})
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
pub fn preflight_pi_models_in_team(team_dir: &Path) -> Result<(), PiModelPreflightError> {
|
|
434
|
+
preflight_pi_models_in_team_with(team_dir, |requested| {
|
|
435
|
+
crate::lifecycle::launch::pi_mcp::pi_model_candidates(requested).map_err(|_| ())
|
|
436
|
+
})
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
pub fn preflight_pi_models_in_team_with<F>(
|
|
440
|
+
team_dir: &Path,
|
|
441
|
+
mut discover: F,
|
|
442
|
+
) -> Result<(), PiModelPreflightError>
|
|
443
|
+
where
|
|
444
|
+
F: FnMut(&str) -> Result<Vec<String>, ()>,
|
|
445
|
+
{
|
|
446
|
+
let agents_dir = team_dir.join("agents");
|
|
447
|
+
let entries = match std::fs::read_dir(&agents_dir) {
|
|
448
|
+
Ok(entries) => entries,
|
|
449
|
+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
|
450
|
+
Err(_) => {
|
|
451
|
+
return Err(PiModelPreflightError {
|
|
452
|
+
requested: "<role directory>".into(),
|
|
453
|
+
candidates: Vec::new(),
|
|
454
|
+
action: "repair the role directory and retry".into(),
|
|
455
|
+
not_ready: true,
|
|
456
|
+
})
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
let mut paths = entries
|
|
460
|
+
.filter_map(Result::ok)
|
|
461
|
+
.map(|entry| entry.path())
|
|
462
|
+
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("md"))
|
|
463
|
+
.collect::<Vec<_>>();
|
|
464
|
+
paths.sort();
|
|
465
|
+
for path in paths {
|
|
466
|
+
let (meta, _) = read_front_matter(&path).map_err(|_| PiModelPreflightError {
|
|
467
|
+
requested: path.display().to_string(),
|
|
468
|
+
candidates: Vec::new(),
|
|
469
|
+
action: "repair the role document and retry".into(),
|
|
470
|
+
not_ready: true,
|
|
471
|
+
})?;
|
|
472
|
+
preflight_pi_role_model_with(&meta, &mut discover)?;
|
|
473
|
+
}
|
|
474
|
+
Ok(())
|
|
475
|
+
}
|
|
476
|
+
|
|
369
477
|
/// 把一份 role 文档编译成 agent spec 条目。`team_meta` 供 model/auth_mode 继承;
|
|
370
478
|
/// `workspace_s` 是 working_directory。**纯读 `role_path`,无任何文件落地。**
|
|
371
479
|
pub fn compile_role_agent(
|
|
@@ -696,6 +804,55 @@ fn int_field(meta: &Value, key: &str, default: i64) -> Value {
|
|
|
696
804
|
}
|
|
697
805
|
}
|
|
698
806
|
|
|
807
|
+
#[cfg(test)]
|
|
808
|
+
mod pi_preflight_tests {
|
|
809
|
+
use super::*;
|
|
810
|
+
use std::cell::Cell;
|
|
811
|
+
|
|
812
|
+
fn role(provider: &str, model: Option<&str>) -> Value {
|
|
813
|
+
let mut fields = vec![("provider".to_string(), Value::Str(provider.into()))];
|
|
814
|
+
if let Some(model) = model {
|
|
815
|
+
fields.push(("model".to_string(), Value::Str(model.into())));
|
|
816
|
+
}
|
|
817
|
+
Value::Map(fields)
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
#[test]
|
|
821
|
+
fn omitted_qualified_and_non_pi_do_not_discover() {
|
|
822
|
+
for meta in [role("pi", None), role("pi", Some("openai/gpt-5")), role("codex", Some("gpt-5"))] {
|
|
823
|
+
let calls = Cell::new(0);
|
|
824
|
+
assert!(preflight_pi_role_model_with(&meta, |_| {
|
|
825
|
+
calls.set(calls.get() + 1);
|
|
826
|
+
Ok(vec![])
|
|
827
|
+
}).is_ok());
|
|
828
|
+
assert_eq!(calls.get(), 0);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
#[test]
|
|
833
|
+
fn unqualified_discovers_once_and_projects_candidates() {
|
|
834
|
+
let calls = Cell::new(0);
|
|
835
|
+
let error = preflight_pi_role_model_with(&role("pi", Some("gpt-5.6-sol")), |requested| {
|
|
836
|
+
calls.set(calls.get() + 1);
|
|
837
|
+
assert_eq!(requested, "gpt-5.6-sol");
|
|
838
|
+
Ok(vec!["openai-codex/gpt-5.6-sol".into(), "azure/gpt-5.6-sol".into()])
|
|
839
|
+
}).expect_err("unqualified model must fail closed");
|
|
840
|
+
assert_eq!(calls.get(), 1);
|
|
841
|
+
assert_eq!(error.candidates, vec!["openai-codex/gpt-5.6-sol", "azure/gpt-5.6-sol"]);
|
|
842
|
+
assert!(!error.not_ready);
|
|
843
|
+
assert!(error.action.contains("team-agent models --provider pi --search gpt-5.6-sol"));
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
#[test]
|
|
847
|
+
fn catalog_failure_is_not_ready_without_partial_candidates() {
|
|
848
|
+
let error = preflight_pi_role_model_with(&role("pi", Some("gpt-5.6-sol")), |_| Err(()))
|
|
849
|
+
.expect_err("catalog failure must reject");
|
|
850
|
+
assert!(error.not_ready);
|
|
851
|
+
assert!(error.candidates.is_empty());
|
|
852
|
+
assert!(!error.to_string().contains("stderr"));
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
699
856
|
fn py_int_value(value: &Value) -> Option<i64> {
|
|
700
857
|
match value {
|
|
701
858
|
Value::Bool(b) => Some(if *b { 1 } else { 0 }),
|