@team-agent/installer 0.3.0 → 0.3.2
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 +38 -7
- package/crates/team-agent/src/cli/emit.rs +182 -54
- package/crates/team-agent/src/cli/mod.rs +703 -35
- package/crates/team-agent/src/cli/status_port.rs +170 -44
- package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -0
- package/crates/team-agent/src/cli/types.rs +1 -0
- package/crates/team-agent/src/coordinator/health.rs +130 -0
- package/crates/team-agent/src/leader/lease.rs +23 -2
- package/crates/team-agent/src/leader/rediscover/tests.rs +1 -0
- package/crates/team-agent/src/leader/rediscover.rs +2 -0
- package/crates/team-agent/src/leader/tests/byte_findings.rs +9 -6
- package/crates/team-agent/src/leader/tests/idle.rs +1 -0
- package/crates/team-agent/src/leader/tests/lease_claim.rs +157 -0
- package/crates/team-agent/src/leader/types.rs +2 -0
- package/crates/team-agent/src/lifecycle/launch.rs +554 -65
- package/crates/team-agent/src/lifecycle/restart/common.rs +65 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +57 -15
- package/crates/team-agent/src/lifecycle/restart/remove.rs +5 -1
- package/crates/team-agent/src/lifecycle/restart.rs +20 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +52 -0
- package/crates/team-agent/src/lifecycle/types.rs +25 -0
- package/crates/team-agent/src/mcp_server/tests/wire.rs +28 -0
- package/crates/team-agent/src/mcp_server/wire.rs +81 -1
- package/crates/team-agent/src/messaging/delivery.rs +574 -12
- package/crates/team-agent/src/messaging/leader_receiver.rs +26 -37
- package/crates/team-agent/src/messaging/mod.rs +1 -1
- package/crates/team-agent/src/messaging/results.rs +218 -49
- package/crates/team-agent/src/messaging/send.rs +15 -19
- package/crates/team-agent/src/provider/adapter.rs +95 -10
- package/crates/team-agent/src/provider/helpers.rs +10 -1
- package/crates/team-agent/src/state/identity.rs +3 -0
- package/crates/team-agent/src/state/persist.rs +113 -1
- package/crates/team-agent/src/state/projection.rs +127 -3
- package/crates/team-agent/src/tmux_backend/tests.rs +179 -0
- package/crates/team-agent/src/tmux_backend.rs +124 -12
- package/npm/install.mjs +29 -7
- package/package.json +4 -4
|
@@ -160,6 +160,71 @@ pub(super) fn agent_rollout_path(agent: &serde_json::Value) -> Option<RolloutPat
|
|
|
160
160
|
.map(RolloutPath::new)
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
pub(crate) fn refresh_missing_provider_sessions(
|
|
164
|
+
state: &mut serde_json::Value,
|
|
165
|
+
) -> Result<bool, LifecycleError> {
|
|
166
|
+
let Some(agents) = state.get_mut("agents").and_then(serde_json::Value::as_object_mut) else {
|
|
167
|
+
return Ok(false);
|
|
168
|
+
};
|
|
169
|
+
let mut changed = false;
|
|
170
|
+
for (agent_id, agent) in agents {
|
|
171
|
+
let Some(agent_obj) = agent.as_object_mut() else {
|
|
172
|
+
continue;
|
|
173
|
+
};
|
|
174
|
+
if agent_obj
|
|
175
|
+
.get("session_id")
|
|
176
|
+
.and_then(serde_json::Value::as_str)
|
|
177
|
+
.is_some_and(|session| !session.is_empty())
|
|
178
|
+
{
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
let Some(spawn_cwd) = agent_obj
|
|
182
|
+
.get("spawn_cwd")
|
|
183
|
+
.and_then(serde_json::Value::as_str)
|
|
184
|
+
.filter(|cwd| !cwd.is_empty())
|
|
185
|
+
else {
|
|
186
|
+
continue;
|
|
187
|
+
};
|
|
188
|
+
let provider = agent_provider(&serde_json::Value::Object(agent_obj.clone()));
|
|
189
|
+
let adapter = crate::provider::get_adapter(provider);
|
|
190
|
+
let captured = adapter
|
|
191
|
+
.capture_session_id(agent_id, Path::new(spawn_cwd), 0)
|
|
192
|
+
.map_err(|e| LifecycleError::Provider(e.to_string()))?;
|
|
193
|
+
let Some(captured) = captured else {
|
|
194
|
+
continue;
|
|
195
|
+
};
|
|
196
|
+
if let Some(session_id) = captured.session_id {
|
|
197
|
+
agent_obj.insert(
|
|
198
|
+
"session_id".to_string(),
|
|
199
|
+
serde_json::json!(session_id.as_str()),
|
|
200
|
+
);
|
|
201
|
+
changed = true;
|
|
202
|
+
}
|
|
203
|
+
if let Some(rollout_path) = captured.rollout_path {
|
|
204
|
+
agent_obj.insert(
|
|
205
|
+
"rollout_path".to_string(),
|
|
206
|
+
serde_json::json!(rollout_path.as_path().to_string_lossy()),
|
|
207
|
+
);
|
|
208
|
+
changed = true;
|
|
209
|
+
}
|
|
210
|
+
agent_obj.insert(
|
|
211
|
+
"captured_at".to_string(),
|
|
212
|
+
serde_json::json!(chrono::Utc::now().to_rfc3339()),
|
|
213
|
+
);
|
|
214
|
+
agent_obj.insert(
|
|
215
|
+
"captured_via".to_string(),
|
|
216
|
+
serde_json::to_value(captured.captured_via)
|
|
217
|
+
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?,
|
|
218
|
+
);
|
|
219
|
+
agent_obj.insert(
|
|
220
|
+
"attribution_confidence".to_string(),
|
|
221
|
+
serde_json::to_value(captured.attribution_confidence)
|
|
222
|
+
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
Ok(changed)
|
|
226
|
+
}
|
|
227
|
+
|
|
163
228
|
/// Tools list off an agent's runtime state entry (`tools: [...]`). Restart paths
|
|
164
229
|
/// don't have the full spec object, only the runtime state — so they read tools from
|
|
165
230
|
/// the state row, falling back to an empty list. Contract C requires the worker
|
|
@@ -31,6 +31,12 @@ pub fn restart_with_transport(
|
|
|
31
31
|
team: Option<&str>,
|
|
32
32
|
transport: &dyn crate::transport::Transport,
|
|
33
33
|
) -> Result<RestartReport, LifecycleError> {
|
|
34
|
+
if crate::lifecycle::restart::input_has_no_local_team_context(workspace) {
|
|
35
|
+
return Err(LifecycleError::TeamSelect(format!(
|
|
36
|
+
"missing spec for restart: {}",
|
|
37
|
+
workspace.join("team.spec.yaml").display()
|
|
38
|
+
)));
|
|
39
|
+
}
|
|
34
40
|
let run_candidate = crate::model::paths::canonical_run_workspace(workspace)
|
|
35
41
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
36
42
|
if !workspace.join("team.spec.yaml").exists()
|
|
@@ -47,7 +53,7 @@ pub fn restart_with_transport(
|
|
|
47
53
|
crate::state::selector::SelectorMode::RequireSpec,
|
|
48
54
|
)
|
|
49
55
|
.map_err(|e| LifecycleError::TeamSelect(e.to_string()))?;
|
|
50
|
-
let state = selected.state;
|
|
56
|
+
let mut state = selected.state;
|
|
51
57
|
crate::lifecycle::launch::ensure_owner_allowed_for_state(&state, None)?;
|
|
52
58
|
let spec_workspace = selected
|
|
53
59
|
.spec_workspace
|
|
@@ -55,6 +61,10 @@ pub fn restart_with_transport(
|
|
|
55
61
|
.ok_or_else(|| LifecycleError::TeamSelect("active team spec workspace not found".to_string()))?;
|
|
56
62
|
let spec = load_team_spec(spec_workspace)?;
|
|
57
63
|
let safety = crate::lifecycle::launch::effective_runtime_config(&spec)?;
|
|
64
|
+
if refresh_missing_provider_sessions(&mut state)? {
|
|
65
|
+
crate::state::projection::save_team_scoped_state(&selected.run_workspace, &state)
|
|
66
|
+
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
67
|
+
}
|
|
58
68
|
let plan = classify_restart_plan(&state, allow_fresh)?;
|
|
59
69
|
write_restart_resume_decision_events(&selected.run_workspace, &state, allow_fresh, &plan.decisions)?;
|
|
60
70
|
if !plan.corrupt_entries.is_empty() {
|
|
@@ -117,7 +127,6 @@ fn write_restart_resume_decision_events(
|
|
|
117
127
|
allow_fresh: bool,
|
|
118
128
|
decisions: &[RestartedAgent],
|
|
119
129
|
) -> Result<(), LifecycleError> {
|
|
120
|
-
let log = crate::event_log::EventLog::new(workspace);
|
|
121
130
|
for decision in decisions {
|
|
122
131
|
let agent = state
|
|
123
132
|
.get("agents")
|
|
@@ -134,23 +143,56 @@ fn write_restart_resume_decision_events(
|
|
|
134
143
|
ResumeDecision::FreshStart => "fresh_start",
|
|
135
144
|
ResumeDecision::Refuse => "refuse",
|
|
136
145
|
};
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
"first_send_at": first_send_at,
|
|
146
|
-
"session_id": session_id,
|
|
147
|
-
}),
|
|
148
|
-
)
|
|
149
|
-
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
146
|
+
write_restart_resume_decision_event(
|
|
147
|
+
workspace,
|
|
148
|
+
decision.agent_id.as_str(),
|
|
149
|
+
first_send_at,
|
|
150
|
+
session_id,
|
|
151
|
+
allow_fresh,
|
|
152
|
+
decision_wire,
|
|
153
|
+
)?;
|
|
150
154
|
}
|
|
151
155
|
Ok(())
|
|
152
156
|
}
|
|
153
157
|
|
|
158
|
+
fn write_restart_resume_decision_event(
|
|
159
|
+
workspace: &Path,
|
|
160
|
+
worker_id: &str,
|
|
161
|
+
first_send_at: Option<String>,
|
|
162
|
+
session_id: Option<String>,
|
|
163
|
+
allow_fresh: bool,
|
|
164
|
+
decision: &str,
|
|
165
|
+
) -> Result<(), LifecycleError> {
|
|
166
|
+
use std::io::Write as _;
|
|
167
|
+
|
|
168
|
+
let path = workspace.join(".team").join("logs").join("events.jsonl");
|
|
169
|
+
if let Some(parent) = path.parent() {
|
|
170
|
+
std::fs::create_dir_all(parent)
|
|
171
|
+
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
172
|
+
}
|
|
173
|
+
let event = serde_json::json!({
|
|
174
|
+
"ts": chrono::Utc::now().to_rfc3339(),
|
|
175
|
+
"event": crate::lifecycle::types::event_names::RESTART_RESUME_DECISION,
|
|
176
|
+
"worker_id": worker_id,
|
|
177
|
+
"has_first_send_at": first_send_at.is_some(),
|
|
178
|
+
"has_session_id": session_id.is_some(),
|
|
179
|
+
"allow_fresh": allow_fresh,
|
|
180
|
+
"decision": decision,
|
|
181
|
+
"first_send_at": first_send_at,
|
|
182
|
+
"session_id": session_id,
|
|
183
|
+
});
|
|
184
|
+
let line = serde_json::to_string(&event)
|
|
185
|
+
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
186
|
+
let mut file = std::fs::OpenOptions::new()
|
|
187
|
+
.create(true)
|
|
188
|
+
.append(true)
|
|
189
|
+
.open(&path)
|
|
190
|
+
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
191
|
+
file.write_all(line.as_bytes())
|
|
192
|
+
.and_then(|_| file.write_all(b"\n"))
|
|
193
|
+
.map_err(|e| LifecycleError::StatePersist(e.to_string()))
|
|
194
|
+
}
|
|
195
|
+
|
|
154
196
|
/// `restart_candidates(workspace)`(`restart/selection.py:12`)。从 snapshot + active
|
|
155
197
|
/// state 收集可重启 team。
|
|
156
198
|
pub fn restart_candidates(workspace: &Path) -> Result<Vec<RestartCandidate>, LifecycleError> {
|
|
@@ -169,7 +169,11 @@ fn remove_agent_inner(
|
|
|
169
169
|
// (team projection) — NOT a raw save, so other teams in a multi-team workspace are preserved.
|
|
170
170
|
let mut removed_state = working_state;
|
|
171
171
|
remove_agent_from_state(&mut removed_state, agent_id)?;
|
|
172
|
-
crate::state::projection::
|
|
172
|
+
crate::state::projection::save_team_scoped_state_with_deleted_agents(
|
|
173
|
+
paths.run_workspace,
|
|
174
|
+
&removed_state,
|
|
175
|
+
&[agent_id.as_str()],
|
|
176
|
+
)
|
|
173
177
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
174
178
|
cleared_locations.push(serde_json::json!("state.json:agents"));
|
|
175
179
|
write_remove_step_event(
|
|
@@ -33,6 +33,7 @@ mod team_state;
|
|
|
33
33
|
|
|
34
34
|
pub use agent::{reset_agent, reset_agent_with_transport, start_agent, start_agent_with_transport, stop_agent, stop_agent_with_transport};
|
|
35
35
|
pub(crate) use agent::start_agent_at_paths;
|
|
36
|
+
pub(crate) use common::refresh_missing_provider_sessions;
|
|
36
37
|
pub use orchestrator::{halt_plan, plan_status};
|
|
37
38
|
pub use rebuild::{restart, restart_candidates, restart_with_transport, select_restart_state};
|
|
38
39
|
pub use remove::{remove_agent, remove_agent_with_transport};
|
|
@@ -45,6 +46,13 @@ pub(crate) fn lifecycle_run_workspace(workspace: &Path) -> Result<std::path::Pat
|
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
fn lifecycle_paths(workspace: &Path, team: Option<&str>) -> Result<LifecyclePaths, LifecycleError> {
|
|
49
|
+
if input_has_no_local_team_context(workspace) {
|
|
50
|
+
return Err(LifecycleError::TeamSelect(format!(
|
|
51
|
+
"active team spec not found: input_workspace={} expected_spec_path={}",
|
|
52
|
+
workspace.display(),
|
|
53
|
+
workspace.join("team.spec.yaml").display()
|
|
54
|
+
)));
|
|
55
|
+
}
|
|
48
56
|
let selected = crate::state::selector::resolve_active_team(
|
|
49
57
|
workspace,
|
|
50
58
|
team,
|
|
@@ -60,6 +68,18 @@ fn lifecycle_paths(workspace: &Path, team: Option<&str>) -> Result<LifecyclePath
|
|
|
60
68
|
})
|
|
61
69
|
}
|
|
62
70
|
|
|
71
|
+
pub(crate) fn input_has_no_local_team_context(workspace: &Path) -> bool {
|
|
72
|
+
!workspace.join("team.spec.yaml").exists()
|
|
73
|
+
&& !workspace.join(".team").exists()
|
|
74
|
+
&& !crate::state::persist::runtime_state_path(workspace).exists()
|
|
75
|
+
&& workspace.file_name().and_then(|s| s.to_str()) != Some(".team")
|
|
76
|
+
&& workspace
|
|
77
|
+
.parent()
|
|
78
|
+
.and_then(|p| p.file_name())
|
|
79
|
+
.and_then(|s| s.to_str())
|
|
80
|
+
!= Some(".team")
|
|
81
|
+
}
|
|
82
|
+
|
|
63
83
|
fn selected_state_spec_workspace(state: &serde_json::Value) -> Option<std::path::PathBuf> {
|
|
64
84
|
state
|
|
65
85
|
.get("spec_path")
|
|
@@ -67,6 +67,58 @@ fn quick_start_compiles_real_spec_to_team_spec_yaml() {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
#[test]
|
|
71
|
+
fn quick_start_teamdir_under_dot_team_uses_project_workspace_for_status_and_collect() {
|
|
72
|
+
let workspace = temp_ws();
|
|
73
|
+
let team = workspace.join(".team").join("current");
|
|
74
|
+
std::fs::create_dir_all(team.join("agents")).unwrap();
|
|
75
|
+
std::fs::write(team.join("TEAM.md"), QS_TEAM_MD).unwrap();
|
|
76
|
+
std::fs::write(team.join("agents").join("implementer.md"), QS_VALID_ROLE).unwrap();
|
|
77
|
+
|
|
78
|
+
let transport = OfflineTransport::new();
|
|
79
|
+
let result = quick_start_with_transport(&team, None, true, true, None, &transport);
|
|
80
|
+
assert!(matches!(result, Ok(QuickStartReport::Ready { .. })), "quick-start failed: {result:?}");
|
|
81
|
+
|
|
82
|
+
let state_path = crate::state::persist::runtime_state_path(&workspace);
|
|
83
|
+
assert!(state_path.exists(), "quick-start .team/current must persist runtime state under project root");
|
|
84
|
+
assert!(
|
|
85
|
+
!workspace.join(".team").join(".team").join("runtime").join("state.json").exists(),
|
|
86
|
+
"quick-start .team/current must not create nested .team/.team runtime state"
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
for input in [&workspace, &team] {
|
|
90
|
+
let selected = crate::state::selector::resolve_active_team(
|
|
91
|
+
input,
|
|
92
|
+
None,
|
|
93
|
+
crate::state::selector::SelectorMode::RuntimeOnly,
|
|
94
|
+
)
|
|
95
|
+
.expect("status/collect selector should resolve project root");
|
|
96
|
+
assert_eq!(selected.run_workspace, workspace, "input={}", input.display());
|
|
97
|
+
assert_eq!(
|
|
98
|
+
selected.spec_path.as_deref().map(std::fs::canonicalize).transpose().unwrap(),
|
|
99
|
+
Some(std::fs::canonicalize(team.join("team.spec.yaml")).unwrap()),
|
|
100
|
+
"input={}",
|
|
101
|
+
input.display()
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let status = crate::cli::cmd_status(&crate::cli::StatusArgs {
|
|
106
|
+
agent: None,
|
|
107
|
+
workspace: team.clone(),
|
|
108
|
+
detail: false,
|
|
109
|
+
summary: false,
|
|
110
|
+
json: true,
|
|
111
|
+
});
|
|
112
|
+
assert!(status.is_ok(), "status should normalize teamdir to project-root runtime state: {status:?}");
|
|
113
|
+
|
|
114
|
+
let collect = crate::cli::cmd_collect(&crate::cli::CollectArgs {
|
|
115
|
+
result_file: None,
|
|
116
|
+
workspace: team.clone(),
|
|
117
|
+
json: true,
|
|
118
|
+
});
|
|
119
|
+
assert!(collect.is_ok(), "collect should normalize teamdir to the same project-root state/spec: {collect:?}");
|
|
120
|
+
}
|
|
121
|
+
|
|
70
122
|
// P0 — quick_start over an INVALID role doc (missing `provider`) must surface the REAL compile
|
|
71
123
|
// error, distinct from the stub's hardcoded "no role docs found". Golden: compile_team raises
|
|
72
124
|
// "missing front matter field provider" (compiler.py:_validate_role_doc), before preflight.
|
|
@@ -445,6 +445,24 @@ pub struct PermissionSummary {
|
|
|
445
445
|
pub raw: serde_json::Value,
|
|
446
446
|
}
|
|
447
447
|
|
|
448
|
+
/// BUG-7 (0.3.1): quick-start cannot honestly report "ready" before the workers'
|
|
449
|
+
/// MCP tool sets have actually loaded — provider-side schema rejections (codex
|
|
450
|
+
/// invalid_function_parameters etc.) happen AFTER spawn and silently disable the
|
|
451
|
+
/// worker. The report must therefore carry a readiness verdict so the CLI surface
|
|
452
|
+
/// never emits bare "ready" while worker capability is unverified or already known
|
|
453
|
+
/// to be degraded.
|
|
454
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
455
|
+
pub enum QuickStartReadiness {
|
|
456
|
+
/// At least one agent already failed to materialize a live tmux window (BUG-2
|
|
457
|
+
/// observable). The team is *not* ready; user must inspect / restart.
|
|
458
|
+
Degraded { unhealthy_agents: Vec<String> },
|
|
459
|
+
/// All spawned agents have live windows but their MCP tool set load has NOT
|
|
460
|
+
/// been verified yet — provider-side schema/auth failures could still leave
|
|
461
|
+
/// the worker unable to call team_orchestrator tools. CLI must label this
|
|
462
|
+
/// `pending` / `unverified`, NOT bare `ready`.
|
|
463
|
+
PendingToolLoad,
|
|
464
|
+
}
|
|
465
|
+
|
|
448
466
|
/// `quick_start(...)` 报告(`diagnose/quick_start.py:103` typed 版)。`Refused` 区分
|
|
449
467
|
/// existing-context(需 restart 或 --fresh)与 preflight 失败。
|
|
450
468
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
@@ -454,6 +472,13 @@ pub enum QuickStartReport {
|
|
|
454
472
|
session_name: SessionName,
|
|
455
473
|
launch: Box<LaunchReport>,
|
|
456
474
|
next_actions: Vec<String>,
|
|
475
|
+
/// BUG-7: real readiness verdict. `Ready` ⇒ the wrapper completed AND the
|
|
476
|
+
/// caller already verified tool-set availability; the framework itself
|
|
477
|
+
/// never emits this without an external observable confirming worker
|
|
478
|
+
/// tool calls succeeded. quick_start_with_transport always defaults to
|
|
479
|
+
/// [`QuickStartReadiness::PendingToolLoad`] (or `Degraded` if any agent
|
|
480
|
+
/// failed to spawn) so the CLI surface cannot lie about availability.
|
|
481
|
+
worker_readiness: QuickStartReadiness,
|
|
457
482
|
},
|
|
458
483
|
/// 已有 runtime state,非 --fresh → 引导用 restart(`quick_start.py:42`)。
|
|
459
484
|
ExistingRuntime {
|
|
@@ -68,6 +68,34 @@
|
|
|
68
68
|
assert_eq!(send["inputSchema"]["required"], json!(["to", "content"]));
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
#[test]
|
|
72
|
+
fn tools_contract_input_schemas_are_openai_strict_top_level_objects() {
|
|
73
|
+
let forbidden = ["oneOf", "anyOf", "allOf", "enum", "not"];
|
|
74
|
+
for tool in tools_contract() {
|
|
75
|
+
let schema = tool["inputSchema"].as_object().unwrap();
|
|
76
|
+
assert_eq!(schema.get("type"), Some(&json!("object")), "schema must be a top-level object: {tool}");
|
|
77
|
+
for key in forbidden {
|
|
78
|
+
assert!(
|
|
79
|
+
!schema.contains_key(key),
|
|
80
|
+
"OpenAI rejects top-level `{key}` in MCP tool schema: {tool}"
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
let properties = schema
|
|
84
|
+
.get("properties")
|
|
85
|
+
.and_then(Value::as_object)
|
|
86
|
+
.unwrap_or_else(|| panic!("schema properties must be an object: {tool}"));
|
|
87
|
+
for required in schema.get("required").and_then(Value::as_array).into_iter().flatten() {
|
|
88
|
+
let Some(name) = required.as_str() else {
|
|
89
|
+
panic!("required entries must be strings: {tool}");
|
|
90
|
+
};
|
|
91
|
+
assert!(
|
|
92
|
+
properties.contains_key(name),
|
|
93
|
+
"required property `{name}` must be declared in properties: {tool}"
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
71
99
|
// ════════════════════════════════════════════════════════════════════════
|
|
72
100
|
// handle_mcp — JSON-RPC routing (server.py:46-91)
|
|
73
101
|
// ════════════════════════════════════════════════════════════════════════
|
|
@@ -298,13 +298,93 @@ fn tool_contract(tool: McpTool) -> Value {
|
|
|
298
298
|
"description": description,
|
|
299
299
|
"inputSchema": {
|
|
300
300
|
"type": "object",
|
|
301
|
-
"properties":
|
|
301
|
+
"properties": tool_properties(tool),
|
|
302
302
|
"required": required,
|
|
303
303
|
"additionalProperties": false
|
|
304
304
|
}
|
|
305
305
|
})
|
|
306
306
|
}
|
|
307
307
|
|
|
308
|
+
fn tool_properties(tool: McpTool) -> serde_json::Map<String, Value> {
|
|
309
|
+
let mut properties = serde_json::Map::new();
|
|
310
|
+
match tool {
|
|
311
|
+
McpTool::AssignTask => {
|
|
312
|
+
insert_property(&mut properties, "task", object_property("Task object to add or update."));
|
|
313
|
+
insert_property(&mut properties, "message", string_property("Optional message to deliver with the task."));
|
|
314
|
+
}
|
|
315
|
+
McpTool::SendMessage => {
|
|
316
|
+
insert_property(&mut properties, "to", string_property("Target agent id, 'leader', or '*' for broadcast."));
|
|
317
|
+
insert_property(&mut properties, "content", string_property("Message body."));
|
|
318
|
+
insert_property(&mut properties, "task_id", string_property("Optional task id to associate with the message."));
|
|
319
|
+
insert_property(&mut properties, "sender", string_property("Optional sender override."));
|
|
320
|
+
insert_property(&mut properties, "requires_ack", boolean_property("Whether the recipient should acknowledge delivery."));
|
|
321
|
+
insert_property(&mut properties, "scope", string_property("Optional delivery scope: team or workspace."));
|
|
322
|
+
}
|
|
323
|
+
McpTool::ReportResult => {
|
|
324
|
+
insert_property(&mut properties, "envelope", object_property("Optional full result envelope."));
|
|
325
|
+
insert_property(&mut properties, "summary", string_property("Short result summary."));
|
|
326
|
+
insert_property(&mut properties, "status", string_property("Result status."));
|
|
327
|
+
insert_property(&mut properties, "changes", array_property("Changed files or artifacts."));
|
|
328
|
+
insert_property(&mut properties, "tests", array_property("Tests or checks performed."));
|
|
329
|
+
insert_property(&mut properties, "risks", array_property("Risks or blockers."));
|
|
330
|
+
insert_property(&mut properties, "artifacts", array_property("Artifact references."));
|
|
331
|
+
insert_property(&mut properties, "next_actions", array_property("Suggested next actions."));
|
|
332
|
+
insert_property(&mut properties, "task_id", string_property("Optional task id override."));
|
|
333
|
+
insert_property(&mut properties, "agent_id", string_property("Optional reporting agent id override."));
|
|
334
|
+
}
|
|
335
|
+
McpTool::UpdateState => {
|
|
336
|
+
insert_property(&mut properties, "note", string_property("Note to append to team state."));
|
|
337
|
+
}
|
|
338
|
+
McpTool::GetTeamStatus | McpTool::StuckList => {}
|
|
339
|
+
McpTool::StopAgent => {
|
|
340
|
+
insert_property(&mut properties, "agent_id", string_property("Agent id to stop."));
|
|
341
|
+
}
|
|
342
|
+
McpTool::ResetAgent => {
|
|
343
|
+
insert_property(&mut properties, "agent_id", string_property("Agent id to reset."));
|
|
344
|
+
insert_property(&mut properties, "discard_session", boolean_property("Whether to discard the existing provider session."));
|
|
345
|
+
}
|
|
346
|
+
McpTool::AddAgent => {
|
|
347
|
+
insert_property(&mut properties, "new_agent_id", string_property("New agent id."));
|
|
348
|
+
insert_property(&mut properties, "role_file_path", string_property("Workspace-relative role file path."));
|
|
349
|
+
}
|
|
350
|
+
McpTool::ForkAgent => {
|
|
351
|
+
insert_property(&mut properties, "source_agent_id", string_property("Agent id to fork from."));
|
|
352
|
+
insert_property(&mut properties, "as_agent_id", string_property("Agent id for the forked worker."));
|
|
353
|
+
insert_property(&mut properties, "label", string_property("Optional display label."));
|
|
354
|
+
}
|
|
355
|
+
McpTool::RequestHuman => {
|
|
356
|
+
insert_property(&mut properties, "question", string_property("Question to ask the human."));
|
|
357
|
+
insert_property(&mut properties, "task_id", string_property("Optional related task id."));
|
|
358
|
+
insert_property(&mut properties, "agent_id", string_property("Optional requesting agent id."));
|
|
359
|
+
}
|
|
360
|
+
McpTool::StuckCancel => {
|
|
361
|
+
insert_property(&mut properties, "agent_id", string_property("Agent id whose stuck alerts should be suppressed."));
|
|
362
|
+
insert_property(&mut properties, "alert_type", string_property("Alert type to suppress, or all."));
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
properties
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
fn insert_property(properties: &mut serde_json::Map<String, Value>, name: &str, schema: Value) {
|
|
369
|
+
properties.insert(name.to_string(), schema);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
fn string_property(description: &str) -> Value {
|
|
373
|
+
serde_json::json!({"type": "string", "description": description})
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
fn boolean_property(description: &str) -> Value {
|
|
377
|
+
serde_json::json!({"type": "boolean", "description": description})
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
fn object_property(description: &str) -> Value {
|
|
381
|
+
serde_json::json!({"type": "object", "description": description, "additionalProperties": true})
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
fn array_property(description: &str) -> Value {
|
|
385
|
+
serde_json::json!({"type": "array", "description": description, "items": {"type": "object", "additionalProperties": true}})
|
|
386
|
+
}
|
|
387
|
+
|
|
308
388
|
pub(crate) fn dispatch_tool(tools: &TeamOrchestratorTools, tool: McpTool, args: &Value) -> ToolResult {
|
|
309
389
|
match tool {
|
|
310
390
|
McpTool::AssignTask => tools.assign_task(args.get("task").unwrap_or(args), args.get("message").and_then(Value::as_str)),
|