@team-agent/installer 0.3.1 → 0.3.3
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 +34 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +234 -26
- package/crates/team-agent/src/cli/diagnose.rs +144 -10
- package/crates/team-agent/src/cli/emit.rs +289 -54
- package/crates/team-agent/src/cli/leader.rs +37 -8
- package/crates/team-agent/src/cli/mod.rs +1281 -196
- package/crates/team-agent/src/cli/status_port.rs +195 -46
- package/crates/team-agent/src/cli/tests/divergence.rs +1 -2
- package/crates/team-agent/src/cli/tests/lane_c.rs +23 -13
- package/crates/team-agent/src/cli/tests/main_preserved.rs +2 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +59 -3
- package/crates/team-agent/src/cli/types.rs +18 -0
- package/crates/team-agent/src/compiler.rs +15 -5
- package/crates/team-agent/src/coordinator/health.rs +95 -17
- package/crates/team-agent/src/coordinator/mod.rs +4 -0
- package/crates/team-agent/src/coordinator/runtime_detectors.rs +500 -0
- package/crates/team-agent/src/coordinator/runtime_observation.rs +58 -0
- package/crates/team-agent/src/coordinator/tick.rs +222 -69
- package/crates/team-agent/src/coordinator/types.rs +15 -3
- package/crates/team-agent/src/db/schema.rs +37 -2
- package/crates/team-agent/src/diagnose/comms.rs +226 -0
- package/crates/team-agent/src/diagnose/mod.rs +45 -0
- package/crates/team-agent/src/diagnose/orphans.rs +658 -0
- package/crates/team-agent/src/fake_worker.rs +146 -3
- package/crates/team-agent/src/leader/start.rs +121 -23
- package/crates/team-agent/src/leader/types.rs +44 -1
- package/crates/team-agent/src/lib.rs +3 -0
- package/crates/team-agent/src/lifecycle/display.rs +645 -47
- package/crates/team-agent/src/lifecycle/launch.rs +1061 -146
- package/crates/team-agent/src/lifecycle/mod.rs +2 -0
- package/crates/team-agent/src/lifecycle/profile_launch.rs +810 -0
- package/crates/team-agent/src/lifecycle/profile_smoke.rs +522 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +99 -23
- package/crates/team-agent/src/lifecycle/restart/common.rs +183 -24
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +498 -22
- package/crates/team-agent/src/lifecycle/restart/remove.rs +27 -7
- package/crates/team-agent/src/lifecycle/restart/team_state.rs +19 -0
- package/crates/team-agent/src/lifecycle/restart.rs +24 -1
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +5 -5
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +37 -7
- package/crates/team-agent/src/lifecycle/types.rs +19 -0
- package/crates/team-agent/src/mcp_server/helpers.rs +1 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +341 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +10 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +158 -0
- package/crates/team-agent/src/mcp_server/mod.rs +3 -74
- package/crates/team-agent/src/mcp_server/tests/scoped.rs +1 -1
- package/crates/team-agent/src/mcp_server/tests/send.rs +6 -5
- package/crates/team-agent/src/mcp_server/tools.rs +312 -111
- package/crates/team-agent/src/mcp_server/types.rs +6 -4
- package/crates/team-agent/src/mcp_server/wire.rs +19 -7
- package/crates/team-agent/src/message_store.rs +21 -4
- package/crates/team-agent/src/messaging/delivery.rs +470 -59
- package/crates/team-agent/src/messaging/mod.rs +9 -6
- package/crates/team-agent/src/messaging/results.rs +353 -63
- package/crates/team-agent/src/messaging/selftest.rs +199 -12
- package/crates/team-agent/src/messaging/send.rs +35 -3
- package/crates/team-agent/src/messaging/tests/runtime.rs +19 -4
- package/crates/team-agent/src/messaging/types.rs +11 -3
- package/crates/team-agent/src/os_probe.rs +119 -0
- package/crates/team-agent/src/packaging/migrate.rs +10 -2
- package/crates/team-agent/src/packaging/tests.rs +23 -0
- package/crates/team-agent/src/provider/adapter.rs +564 -63
- package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +1 -7
- package/crates/team-agent/src/provider/classify.rs +51 -4
- package/crates/team-agent/src/provider/helpers.rs +10 -1
- package/crates/team-agent/src/provider/startup_prompt.rs +94 -0
- package/crates/team-agent/src/provider/types.rs +47 -0
- package/crates/team-agent/src/session_capture.rs +616 -0
- package/crates/team-agent/src/state/persist.rs +170 -1
- package/crates/team-agent/src/state/projection.rs +141 -8
- package/crates/team-agent/src/state/selector.rs +5 -2
- package/crates/team-agent/src/tmux_backend.rs +161 -64
- package/crates/team-agent/src/transport/test_support.rs +9 -0
- package/crates/team-agent/src/transport/tests/wire.rs +4 -0
- package/crates/team-agent/src/transport.rs +13 -2
- package/package.json +4 -4
|
@@ -10,7 +10,6 @@ use crate::model::ids::{AgentId, TaskId, TeamKey};
|
|
|
10
10
|
|
|
11
11
|
// ── REUSE: step 4 event_log / step 7 message_store ──────────────────────────
|
|
12
12
|
use crate::event_log::EventLog;
|
|
13
|
-
use crate::message_store::MessageStore;
|
|
14
13
|
|
|
15
14
|
// ── REUSE: step 5 state persist / projection ────────────────────────────────
|
|
16
15
|
use crate::state::persist::{load_runtime_state, save_runtime_state};
|
|
@@ -24,7 +23,7 @@ use super::helpers::{
|
|
|
24
23
|
requires_ack_for_target, tool_runtime_error,
|
|
25
24
|
};
|
|
26
25
|
use super::normalize::{compact_tool_result, normalize_report_envelope};
|
|
27
|
-
use super::types::{
|
|
26
|
+
use super::types::{Scope, SendOutcome, ToolError, ToolErrorReason, ToolOk, ToolResult, VisiblePeers};
|
|
28
27
|
|
|
29
28
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
30
29
|
// TeamOrchestratorTools (tools.py:72) — the 12 typed tool handlers.
|
|
@@ -75,6 +74,7 @@ impl TeamOrchestratorTools {
|
|
|
75
74
|
/// field-updates the task in state, then delegates delivery to
|
|
76
75
|
/// [`Self::send_message`] and compacts the result.
|
|
77
76
|
pub fn assign_task(&self, task: &Value, message: Option<&str>) -> ToolResult {
|
|
77
|
+
self.validate_rpc_scope_args("assign_task", task)?;
|
|
78
78
|
let Some(task_obj) = task.as_object() else {
|
|
79
79
|
return Err(ToolError::new(
|
|
80
80
|
ToolErrorReason::InvalidToolArguments,
|
|
@@ -104,7 +104,10 @@ impl TeamOrchestratorTools {
|
|
|
104
104
|
let task_value = Value::Object(task_obj.clone());
|
|
105
105
|
let mut state = load_runtime_state(&self.workspace).map_err(tool_runtime_error)?;
|
|
106
106
|
ensure_object(&mut state);
|
|
107
|
-
let team_key =
|
|
107
|
+
let team_key = self
|
|
108
|
+
.canonical_owner_team_key()?
|
|
109
|
+
.map(|team| team.as_str().to_string())
|
|
110
|
+
.or_else(|| assignment_team_key(&state));
|
|
108
111
|
reconcile_assigned_task(&mut state, team_key.as_deref(), &task_value);
|
|
109
112
|
save_runtime_state(&self.workspace, &state).map_err(tool_runtime_error)?;
|
|
110
113
|
|
|
@@ -136,9 +139,17 @@ impl TeamOrchestratorTools {
|
|
|
136
139
|
task_id: Option<&str>,
|
|
137
140
|
sender: Option<&str>,
|
|
138
141
|
requires_ack: Option<bool>,
|
|
139
|
-
|
|
142
|
+
scope_override: Option<Scope>,
|
|
140
143
|
) -> Result<SendOutcome, ToolError> {
|
|
141
|
-
|
|
144
|
+
let canonical_owner_team = self.canonical_owner_team_key()?;
|
|
145
|
+
if matches!(scope_override, Some(Scope::Workspace)) {
|
|
146
|
+
return Err(self.rpc_scope_refused(
|
|
147
|
+
"send_message",
|
|
148
|
+
None,
|
|
149
|
+
scope_override.and_then(scope_override_name),
|
|
150
|
+
));
|
|
151
|
+
}
|
|
152
|
+
if let Some(err) = self.refuse_cross_team_peer(to, None) {
|
|
142
153
|
return Err(err);
|
|
143
154
|
}
|
|
144
155
|
let sender = sender
|
|
@@ -156,7 +167,7 @@ impl TeamOrchestratorTools {
|
|
|
156
167
|
serde_json::json!({
|
|
157
168
|
"tool": "send_message",
|
|
158
169
|
"sender": sender,
|
|
159
|
-
"owner_team_id":
|
|
170
|
+
"owner_team_id": canonical_owner_team.as_ref().map(TeamKey::as_str),
|
|
160
171
|
"to": match to {
|
|
161
172
|
MessageTarget::Single(t) => serde_json::Value::String(t.clone()),
|
|
162
173
|
MessageTarget::Broadcast => serde_json::Value::String("*".to_string()),
|
|
@@ -168,42 +179,77 @@ impl TeamOrchestratorTools {
|
|
|
168
179
|
}),
|
|
169
180
|
)
|
|
170
181
|
.map_err(tool_runtime_error)?;
|
|
171
|
-
if is_worker_recipient(to) {
|
|
172
|
-
let recipient = match to {
|
|
173
|
-
MessageTarget::Single(value) => value.as_str(),
|
|
174
|
-
MessageTarget::Broadcast | MessageTarget::Fanout(_) => "worker",
|
|
175
|
-
};
|
|
176
|
-
let store = MessageStore::open(&self.workspace).map_err(tool_runtime_error)?;
|
|
177
|
-
let message_id = store
|
|
178
|
-
.create_message(
|
|
179
|
-
task_id,
|
|
180
|
-
sender,
|
|
181
|
-
recipient,
|
|
182
|
-
content,
|
|
183
|
-
None,
|
|
184
|
-
ack,
|
|
185
|
-
self.owner_team_id.as_ref().map(TeamKey::as_str),
|
|
186
|
-
)
|
|
187
|
-
.map_err(tool_runtime_error)?;
|
|
188
|
-
return Ok(SendOutcome::WorkerAccepted {
|
|
189
|
-
poll_via: format!("team-agent inbox {message_id}"),
|
|
190
|
-
message_id,
|
|
191
|
-
});
|
|
192
|
-
}
|
|
193
182
|
let opts = SendOptions {
|
|
194
183
|
task_id: task_id.map(TaskId::new),
|
|
195
184
|
route_task_id: true,
|
|
196
185
|
sender: sender.to_string(),
|
|
197
186
|
requires_ack: ack,
|
|
198
|
-
team:
|
|
187
|
+
team: canonical_owner_team,
|
|
199
188
|
..SendOptions::default()
|
|
200
189
|
};
|
|
190
|
+
if is_worker_recipient(to) {
|
|
191
|
+
let out = messaging::send_message(&self.workspace, to, content, &opts).map_err(tool_runtime_error)?;
|
|
192
|
+
let message_id = match out.message_id {
|
|
193
|
+
Some(message_id) if out.ok => message_id,
|
|
194
|
+
None if self.owner_team_id.is_none() => {
|
|
195
|
+
format!("mcp_{}", chrono::Utc::now().timestamp_micros())
|
|
196
|
+
}
|
|
197
|
+
_ => {
|
|
198
|
+
let value = delivery_outcome_value(&out);
|
|
199
|
+
let ok = compact_tool_result(&value)?;
|
|
200
|
+
return Ok(SendOutcome::Direct(ok));
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
return Ok(SendOutcome::WorkerAccepted {
|
|
204
|
+
poll_via: format!("team-agent inbox {message_id}"),
|
|
205
|
+
message_id,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
201
208
|
let out = messaging::send_message(&self.workspace, to, content, &opts).map_err(tool_runtime_error)?;
|
|
202
209
|
let value = delivery_outcome_value(&out);
|
|
203
210
|
let ok = compact_tool_result(&value)?;
|
|
204
211
|
Ok(SendOutcome::Direct(ok))
|
|
205
212
|
}
|
|
206
213
|
|
|
214
|
+
pub(crate) fn refuse_scope_override(&self) -> ToolError {
|
|
215
|
+
self.rpc_scope_refused("unknown", None, None)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
pub(crate) fn validate_rpc_scope_args(&self, tool: &str, args: &Value) -> Result<(), ToolError> {
|
|
219
|
+
if let Some(nested) = args.get("task").or_else(|| args.get("envelope")) {
|
|
220
|
+
self.validate_rpc_scope_args(tool, nested)?;
|
|
221
|
+
}
|
|
222
|
+
let owner_team = self.canonical_owner_team_key()?;
|
|
223
|
+
let requested_team = requested_team_arg(args);
|
|
224
|
+
let requested_scope = requested_scope_arg(args);
|
|
225
|
+
let workspace_override = args.get("workspace").is_some();
|
|
226
|
+
let scope_widens = requested_scope
|
|
227
|
+
.as_deref()
|
|
228
|
+
.is_some_and(|scope| !scope.eq_ignore_ascii_case("team"));
|
|
229
|
+
let team_widens = match (owner_team.as_ref(), requested_team.as_deref()) {
|
|
230
|
+
(_, None) => false,
|
|
231
|
+
(Some(owner), Some(requested)) => {
|
|
232
|
+
let state = load_runtime_state(&self.workspace).unwrap_or(serde_json::json!({}));
|
|
233
|
+
let requested_canonical = crate::state::projection::resolve_owner_team_id(&state, requested)
|
|
234
|
+
.canonical_key()
|
|
235
|
+
.unwrap_or(requested)
|
|
236
|
+
.to_string();
|
|
237
|
+
requested_canonical != owner.as_str()
|
|
238
|
+
}
|
|
239
|
+
(None, Some(_)) => true,
|
|
240
|
+
};
|
|
241
|
+
if workspace_override || scope_widens || team_widens {
|
|
242
|
+
return Err(self.rpc_scope_refused(
|
|
243
|
+
tool,
|
|
244
|
+
requested_team.as_deref(),
|
|
245
|
+
requested_scope
|
|
246
|
+
.as_deref()
|
|
247
|
+
.or_else(|| workspace_override.then_some("workspace")),
|
|
248
|
+
));
|
|
249
|
+
}
|
|
250
|
+
Ok(())
|
|
251
|
+
}
|
|
252
|
+
|
|
207
253
|
/// `report_result` (`tools.py:249-279`): build & normalize the result envelope
|
|
208
254
|
/// (inferring `task_id`/`agent_id` with byte-stable `"manual"`/`"unknown"`
|
|
209
255
|
/// fallbacks), then delegate to [`messaging::report_result`] and compact.
|
|
@@ -221,6 +267,9 @@ impl TeamOrchestratorTools {
|
|
|
221
267
|
task_id: Option<&str>,
|
|
222
268
|
agent_id: Option<&str>,
|
|
223
269
|
) -> ToolResult {
|
|
270
|
+
if let Some(envelope) = envelope {
|
|
271
|
+
self.validate_rpc_scope_args("report_result", envelope)?;
|
|
272
|
+
}
|
|
224
273
|
let mut base = envelope.cloned().unwrap_or_else(|| Value::Object(serde_json::Map::new()));
|
|
225
274
|
ensure_object(&mut base);
|
|
226
275
|
if let Some(obj) = base.as_object_mut() {
|
|
@@ -267,93 +316,92 @@ impl TeamOrchestratorTools {
|
|
|
267
316
|
}
|
|
268
317
|
let normalized = normalize_report_envelope(&base);
|
|
269
318
|
let env_value = normalized_envelope_value(&normalized);
|
|
270
|
-
|
|
319
|
+
let owner_team = self.canonical_owner_team_key()?;
|
|
320
|
+
messaging::report_result_for_owner_team(
|
|
321
|
+
&self.workspace,
|
|
322
|
+
&env_value,
|
|
323
|
+
owner_team.as_ref().map(TeamKey::as_str),
|
|
324
|
+
)
|
|
271
325
|
.map_err(tool_runtime_error)
|
|
272
326
|
.and_then(|value| compact_tool_result(&value))
|
|
273
327
|
}
|
|
274
328
|
|
|
275
|
-
/// `update_state` (`tools.py:316-325`):
|
|
276
|
-
///
|
|
277
|
-
/// `{ok:true, state_file:<path>}`.
|
|
278
|
-
///
|
|
279
|
-
/// [`write_team_state`]: super::lifecycle_placeholder::write_team_state
|
|
329
|
+
/// `update_state` (`tools.py:316-325`): delegated through the lifecycle tools
|
|
330
|
+
/// facade. S0 preserves the old placeholder behavior.
|
|
280
331
|
pub fn update_state(&self, note: &str) -> ToolResult {
|
|
281
|
-
let
|
|
282
|
-
|
|
283
|
-
if let Some(obj) = state.as_object_mut() {
|
|
284
|
-
let notes = obj
|
|
285
|
-
.entry("notes".to_string())
|
|
286
|
-
.or_insert_with(|| Value::Array(Vec::new()));
|
|
287
|
-
if !notes.is_array() {
|
|
288
|
-
*notes = Value::Array(Vec::new());
|
|
289
|
-
}
|
|
290
|
-
if let Some(items) = notes.as_array_mut() {
|
|
291
|
-
items.push(Value::String(note.to_string()));
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
save_runtime_state(&self.workspace, &state).map_err(tool_runtime_error)?;
|
|
295
|
-
let path = super::lifecycle_placeholder::write_team_state(&self.workspace, &Value::Null, &state)
|
|
296
|
-
.map_err(tool_runtime_error)?;
|
|
297
|
-
let mut fields = serde_json::Map::new();
|
|
298
|
-
fields.insert("ok".to_string(), Value::Bool(true));
|
|
299
|
-
fields.insert("state_file".to_string(), Value::String(path.to_string_lossy().to_string()));
|
|
300
|
-
Ok(ToolOk { fields })
|
|
332
|
+
let owner_team = self.canonical_owner_team_key()?;
|
|
333
|
+
super::lifecycle_tools::update_state(&self.workspace, owner_team.as_ref(), note)
|
|
301
334
|
}
|
|
302
335
|
|
|
303
|
-
/// `get_team_status` (`tools.py:327-328`):
|
|
304
|
-
///
|
|
305
|
-
/// [`runtime_status`]). Returns the compact status object verbatim.
|
|
306
|
-
///
|
|
307
|
-
/// [`runtime_status`]: super::lifecycle_placeholder::runtime_status
|
|
336
|
+
/// `get_team_status` (`tools.py:327-328`): delegated through the lifecycle tools
|
|
337
|
+
/// facade. S0 preserves the old placeholder behavior.
|
|
308
338
|
pub fn get_team_status(&self) -> ToolResult {
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
Err(err) => Err(tool_runtime_error(err)),
|
|
312
|
-
}
|
|
339
|
+
let owner_team = self.canonical_owner_team_key()?;
|
|
340
|
+
super::lifecycle_tools::get_team_status(&self.workspace, owner_team.as_ref())
|
|
313
341
|
}
|
|
314
342
|
|
|
315
|
-
/// `stop_agent` (`tools.py:330-331`):
|
|
316
|
-
///
|
|
317
|
-
/// [`stop_agent`]: super::lifecycle_placeholder::stop_agent
|
|
343
|
+
/// `stop_agent` (`tools.py:330-331`): delegated through the lifecycle tools facade.
|
|
318
344
|
pub fn stop_agent(&self, agent_id: &str) -> ToolResult {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
.and_then(|v| compact_tool_result(&v))
|
|
345
|
+
let owner_team = self.canonical_owner_team_key()?;
|
|
346
|
+
super::lifecycle_tools::stop_agent(&self.workspace, owner_team.as_ref(), agent_id)
|
|
322
347
|
}
|
|
323
348
|
|
|
324
|
-
/// `reset_agent` (`tools.py:333-334`):
|
|
325
|
-
/// (`discard_session`), compact.
|
|
326
|
-
///
|
|
327
|
-
/// [`reset_agent`]: super::lifecycle_placeholder::reset_agent
|
|
349
|
+
/// `reset_agent` (`tools.py:333-334`): delegated through the lifecycle tools facade.
|
|
328
350
|
pub fn reset_agent(&self, agent_id: &str, discard_session: bool) -> ToolResult {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
.and_then(|v| compact_tool_result(&v))
|
|
351
|
+
let owner_team = self.canonical_owner_team_key()?;
|
|
352
|
+
super::lifecycle_tools::reset_agent(&self.workspace, owner_team.as_ref(), agent_id, discard_session)
|
|
332
353
|
}
|
|
333
354
|
|
|
334
|
-
/// `add_agent` (`tools.py:336-337`): delegate to
|
|
335
|
-
///
|
|
336
|
-
///
|
|
337
|
-
/// [`add_agent`]: super::lifecycle_placeholder::add_agent
|
|
355
|
+
/// `add_agent` (`tools.py:336-337`): delegate to real lifecycle add-agent
|
|
356
|
+
/// under the spawn-time owner team.
|
|
338
357
|
pub fn add_agent(&self, new_agent_id: &str, role_file_path: &str) -> ToolResult {
|
|
339
|
-
|
|
340
|
-
.
|
|
341
|
-
.
|
|
358
|
+
let owner_team = self
|
|
359
|
+
.canonical_owner_team_key()?
|
|
360
|
+
.ok_or_else(|| self.scope_refused("add_agent requires TEAM_AGENT_OWNER_TEAM_ID"))?;
|
|
361
|
+
let role_file = Path::new(role_file_path);
|
|
362
|
+
let role_file = if role_file.is_absolute() {
|
|
363
|
+
role_file.to_path_buf()
|
|
364
|
+
} else {
|
|
365
|
+
self.workspace.join(role_file)
|
|
366
|
+
};
|
|
367
|
+
crate::lifecycle::launch::add_agent(
|
|
368
|
+
&self.workspace,
|
|
369
|
+
&AgentId::new(new_agent_id.to_string()),
|
|
370
|
+
&role_file,
|
|
371
|
+
false,
|
|
372
|
+
Some(owner_team.as_str()),
|
|
373
|
+
)
|
|
374
|
+
.map_err(tool_runtime_error)
|
|
375
|
+
.and_then(|report| {
|
|
376
|
+
compact_tool_result(&serde_json::json!({
|
|
377
|
+
"ok": true,
|
|
378
|
+
"status": "added",
|
|
379
|
+
"agent_id": new_agent_id,
|
|
380
|
+
"state_file": report.env.state_file.to_string_lossy().to_string(),
|
|
381
|
+
"coordinator_started": report.env.coordinator_started,
|
|
382
|
+
"start_mode": format!("{:?}", report.start_mode),
|
|
383
|
+
"role_file": report.role_file.to_string_lossy().to_string(),
|
|
384
|
+
}))
|
|
385
|
+
})
|
|
342
386
|
}
|
|
343
387
|
|
|
344
|
-
/// `fork_agent` (`tools.py:339-340`):
|
|
345
|
-
///
|
|
346
|
-
/// [`fork_agent`]: super::lifecycle_placeholder::fork_agent
|
|
388
|
+
/// `fork_agent` (`tools.py:339-340`): delegated through the lifecycle tools facade.
|
|
347
389
|
pub fn fork_agent(&self, source_agent_id: &str, as_agent_id: &str, label: Option<&str>) -> ToolResult {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
.
|
|
390
|
+
let owner_team = self.canonical_owner_team_key()?;
|
|
391
|
+
super::lifecycle_tools::fork_agent(
|
|
392
|
+
&self.workspace,
|
|
393
|
+
owner_team.as_ref(),
|
|
394
|
+
source_agent_id,
|
|
395
|
+
as_agent_id,
|
|
396
|
+
label,
|
|
397
|
+
)
|
|
351
398
|
}
|
|
352
399
|
|
|
353
400
|
/// `request_human` (`tools.py:342-346`): create a `requires_ack` leader message via
|
|
354
|
-
///
|
|
355
|
-
/// `{ok:true, message_id, status:"needs_human"}`.
|
|
401
|
+
/// the shared leader-delivery funnel; sender = env / inferred / `"unknown"`.
|
|
402
|
+
/// Returns `{ok:true, message_id, status:"needs_human"}`.
|
|
356
403
|
pub fn request_human(&self, question: &str, task_id: Option<&str>, agent_id: Option<&str>) -> ToolResult {
|
|
404
|
+
let _owner_team = self.canonical_owner_team_key()?;
|
|
357
405
|
let explicit_sender = agent_id.and_then(non_empty_string);
|
|
358
406
|
let sender = explicit_sender
|
|
359
407
|
.or_else(|| self.agent_id.as_ref().map(AgentId::as_str))
|
|
@@ -369,7 +417,7 @@ impl TeamOrchestratorTools {
|
|
|
369
417
|
}
|
|
370
418
|
// #230 N31/N32 funnel: request_human is a leader-bound caller and must go through
|
|
371
419
|
// the same primitive as send_message(to=leader) / report_result / idle reminder.
|
|
372
|
-
// The legacy path was a raw
|
|
420
|
+
// The legacy path was a raw store insert for recipient="leader" that
|
|
373
421
|
// bypassed the leader-delivery audit (no deliver_to_leader.submit emit, no rebind
|
|
374
422
|
// guard, no leader_notification_log dedup). funnel it now.
|
|
375
423
|
let state = crate::state::persist::load_runtime_state(&self.workspace)
|
|
@@ -400,6 +448,7 @@ impl TeamOrchestratorTools {
|
|
|
400
448
|
/// `stuck_list` (`tools.py:348-349`): delegate to [`messaging::stuck_list`] (the
|
|
401
449
|
/// team-scoped suppressed-alert projection).
|
|
402
450
|
pub fn stuck_list(&self) -> ToolResult {
|
|
451
|
+
let _owner_team = self.canonical_owner_team_key()?;
|
|
403
452
|
messaging::stuck_list(&self.workspace)
|
|
404
453
|
.map_err(tool_runtime_error)
|
|
405
454
|
.map(|v| ToolOk { fields: object_fields(v) })
|
|
@@ -408,6 +457,7 @@ impl TeamOrchestratorTools {
|
|
|
408
457
|
/// `stuck_cancel` (`tools.py:351-352`): delegate to [`messaging::stuck_cancel`];
|
|
409
458
|
/// `suppressed_by` = env agent_id / `"leader"`.
|
|
410
459
|
pub fn stuck_cancel(&self, agent_id: &str, alert_type: &str) -> ToolResult {
|
|
460
|
+
let _owner_team = self.canonical_owner_team_key()?;
|
|
411
461
|
let alert = match alert_type {
|
|
412
462
|
"stuck" => Some(messaging::AlertType::Stuck),
|
|
413
463
|
"idle_fallback" => Some(messaging::AlertType::IdleFallback),
|
|
@@ -424,10 +474,10 @@ impl TeamOrchestratorTools {
|
|
|
424
474
|
/// `get_visible_peers` (`tools.py:226-247`): C16 scope-filtered peer list — live
|
|
425
475
|
/// agents within the spawn-time owner-team scope only; other teams and dead/stopped
|
|
426
476
|
/// agents are filtered server-side and never named.
|
|
427
|
-
pub fn get_visible_peers(&self) -> Result<VisiblePeers,
|
|
477
|
+
pub fn get_visible_peers(&self) -> Result<VisiblePeers, ToolError> {
|
|
428
478
|
let mut peers = Vec::new();
|
|
429
|
-
if let Some(team) =
|
|
430
|
-
let state = load_runtime_state(&self.workspace)?;
|
|
479
|
+
if let Some(team) = self.canonical_owner_team_key_for_mcp()? {
|
|
480
|
+
let state = load_runtime_state(&self.workspace).map_err(tool_runtime_error)?;
|
|
431
481
|
if let Some(agents) = state
|
|
432
482
|
.get("teams")
|
|
433
483
|
.and_then(|v| v.get(team.as_str()))
|
|
@@ -451,7 +501,7 @@ impl TeamOrchestratorTools {
|
|
|
451
501
|
peers.sort_by(|a, b| a.as_str().cmp(b.as_str()));
|
|
452
502
|
Ok(VisiblePeers {
|
|
453
503
|
peers,
|
|
454
|
-
sender_team_id: self.
|
|
504
|
+
sender_team_id: self.canonical_owner_team_key_for_mcp()?,
|
|
455
505
|
scope: Scope::Team,
|
|
456
506
|
})
|
|
457
507
|
}
|
|
@@ -460,8 +510,19 @@ impl TeamOrchestratorTools {
|
|
|
460
510
|
/// non-`*`/non-leader string target NOT in the visible-peer scope and not the
|
|
461
511
|
/// sender itself, with `scope != workspace`, → `Some(ToolError{PeerNotInScope})`
|
|
462
512
|
/// (also writes `mcp.send_message_refused`). `None` = allowed to proceed.
|
|
463
|
-
pub fn refuse_cross_team_peer(&self, to: &MessageTarget,
|
|
464
|
-
|
|
513
|
+
pub fn refuse_cross_team_peer(&self, to: &MessageTarget, scope_override: Option<Scope>) -> Option<ToolError> {
|
|
514
|
+
let owner_team = match self.canonical_owner_team_key() {
|
|
515
|
+
Ok(team) => team,
|
|
516
|
+
Err(error) => return Some(error),
|
|
517
|
+
};
|
|
518
|
+
if matches!(scope_override, Some(Scope::Workspace)) {
|
|
519
|
+
return Some(self.rpc_scope_refused(
|
|
520
|
+
"send_message",
|
|
521
|
+
None,
|
|
522
|
+
scope_override.and_then(scope_override_name),
|
|
523
|
+
));
|
|
524
|
+
}
|
|
525
|
+
if owner_team.is_none() {
|
|
465
526
|
return None;
|
|
466
527
|
}
|
|
467
528
|
let MessageTarget::Single(target) = to else {
|
|
@@ -480,12 +541,12 @@ impl TeamOrchestratorTools {
|
|
|
480
541
|
return None;
|
|
481
542
|
}
|
|
482
543
|
}
|
|
483
|
-
let hint = "the requested peer is not part of your team
|
|
544
|
+
let hint = "the requested peer is not part of your team; worker-origin MCP cannot widen team scope.";
|
|
484
545
|
let _ = EventLog::new(&self.workspace).write(
|
|
485
546
|
"mcp.send_message_refused",
|
|
486
547
|
serde_json::json!({
|
|
487
548
|
"reason": "peer_not_in_scope",
|
|
488
|
-
"sender_team_id":
|
|
549
|
+
"sender_team_id": owner_team.as_ref().map(TeamKey::as_str).unwrap_or(""),
|
|
489
550
|
"scope": "team",
|
|
490
551
|
"hint": hint
|
|
491
552
|
}),
|
|
@@ -503,18 +564,151 @@ impl TeamOrchestratorTools {
|
|
|
503
564
|
extra,
|
|
504
565
|
})
|
|
505
566
|
}
|
|
567
|
+
|
|
568
|
+
fn canonical_owner_team_key(&self) -> Result<Option<TeamKey>, ToolError> {
|
|
569
|
+
// Single worker MCP owner resolver: TEAM_AGENT_OWNER_TEAM_ID must resolve
|
|
570
|
+
// through state::projection::resolve_owner_team_id to the runtime team key.
|
|
571
|
+
// Unresolved/ambiguous owner scope emits mcp.scope_refused; never fallback
|
|
572
|
+
// to active/top-level/sibling teams in a multi-team state.
|
|
573
|
+
let Some(owner_team_id) = &self.owner_team_id else {
|
|
574
|
+
let state = load_runtime_state(&self.workspace).unwrap_or(serde_json::json!({}));
|
|
575
|
+
if state
|
|
576
|
+
.get("teams")
|
|
577
|
+
.and_then(Value::as_object)
|
|
578
|
+
.is_some_and(|teams| !teams.is_empty())
|
|
579
|
+
{
|
|
580
|
+
return Err(self.scope_refused("TEAM_AGENT_OWNER_TEAM_ID is required for multi-team MCP"));
|
|
581
|
+
}
|
|
582
|
+
return Ok(None);
|
|
583
|
+
};
|
|
584
|
+
let state = load_runtime_state(&self.workspace)
|
|
585
|
+
.map_err(|_| self.scope_refused("owner team could not be resolved"))?;
|
|
586
|
+
match canonicalize_owner_team_id(&state, owner_team_id.as_str()) {
|
|
587
|
+
Some(team) => Ok(Some(TeamKey::new(team))),
|
|
588
|
+
None => Err(self.scope_refused("owner team could not be resolved")),
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
fn canonical_owner_team_key_for_mcp(&self) -> Result<Option<TeamKey>, ToolError> {
|
|
593
|
+
let Some(owner_team_id) = &self.owner_team_id else {
|
|
594
|
+
let state = load_runtime_state(&self.workspace).unwrap_or(serde_json::json!({}));
|
|
595
|
+
if state
|
|
596
|
+
.get("teams")
|
|
597
|
+
.and_then(Value::as_object)
|
|
598
|
+
.is_some_and(|teams| !teams.is_empty())
|
|
599
|
+
{
|
|
600
|
+
return Err(self.scope_refused("TEAM_AGENT_OWNER_TEAM_ID is required for multi-team MCP"));
|
|
601
|
+
}
|
|
602
|
+
return Ok(None);
|
|
603
|
+
};
|
|
604
|
+
let state = load_runtime_state(&self.workspace)
|
|
605
|
+
.map_err(|_| self.scope_refused("owner team could not be resolved"))?;
|
|
606
|
+
match canonicalize_owner_team_id(&state, owner_team_id.as_str()) {
|
|
607
|
+
Some(team) => Ok(Some(TeamKey::new(team))),
|
|
608
|
+
None => Err(self.scope_refused("owner team could not be resolved")),
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
fn scope_refused(&self, message: &str) -> ToolError {
|
|
613
|
+
let canonical_owner_team_id = self.canonical_owner_team_key_for_event();
|
|
614
|
+
let _ = EventLog::new(&self.workspace).write(
|
|
615
|
+
"mcp.scope_refused",
|
|
616
|
+
serde_json::json!({
|
|
617
|
+
"reason": "scope_refused",
|
|
618
|
+
"requested_owner_team_id": self.owner_team_id.as_ref().map(TeamKey::as_str),
|
|
619
|
+
"owner_team_id": canonical_owner_team_id,
|
|
620
|
+
"canonical_owner_team_id": canonical_owner_team_id,
|
|
621
|
+
"message": message,
|
|
622
|
+
}),
|
|
623
|
+
);
|
|
624
|
+
let mut extra = serde_json::Map::new();
|
|
625
|
+
extra.insert("status".to_string(), Value::String("refused".to_string()));
|
|
626
|
+
extra.insert("hint".to_string(), Value::String(message.to_string()));
|
|
627
|
+
ToolError {
|
|
628
|
+
reason: ToolErrorReason::McpScopeRefused,
|
|
629
|
+
exc_type: "McpScopeRefused".to_string(),
|
|
630
|
+
message: "mcp.scope_refused".to_string(),
|
|
631
|
+
extra,
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
fn rpc_scope_refused(
|
|
636
|
+
&self,
|
|
637
|
+
tool: &str,
|
|
638
|
+
requested_team: Option<&str>,
|
|
639
|
+
requested_scope: Option<&str>,
|
|
640
|
+
) -> ToolError {
|
|
641
|
+
let owner_team_id = self.canonical_owner_team_key_for_event();
|
|
642
|
+
let agent_id = self.agent_id.as_ref().map(AgentId::as_str).unwrap_or("unknown");
|
|
643
|
+
let _ = EventLog::new(&self.workspace).write(
|
|
644
|
+
"mcp.scope_refused",
|
|
645
|
+
serde_json::json!({
|
|
646
|
+
"reason": "rpc_scope_override",
|
|
647
|
+
"tool": tool,
|
|
648
|
+
"agent_id": agent_id,
|
|
649
|
+
"owner_team_id": owner_team_id,
|
|
650
|
+
"requested_team": requested_team,
|
|
651
|
+
"requested_scope": requested_scope,
|
|
652
|
+
}),
|
|
653
|
+
);
|
|
654
|
+
let mut extra = serde_json::Map::new();
|
|
655
|
+
extra.insert("status".to_string(), Value::String("refused".to_string()));
|
|
656
|
+
extra.insert("tool".to_string(), Value::String(tool.to_string()));
|
|
657
|
+
extra.insert("agent_id".to_string(), Value::String(agent_id.to_string()));
|
|
658
|
+
extra.insert(
|
|
659
|
+
"owner_team_id".to_string(),
|
|
660
|
+
owner_team_id.map_or(Value::Null, Value::String),
|
|
661
|
+
);
|
|
662
|
+
extra.insert(
|
|
663
|
+
"requested_team".to_string(),
|
|
664
|
+
requested_team.map_or(Value::Null, |team| Value::String(team.to_string())),
|
|
665
|
+
);
|
|
666
|
+
extra.insert(
|
|
667
|
+
"requested_scope".to_string(),
|
|
668
|
+
requested_scope.map_or(Value::Null, |scope| Value::String(scope.to_string())),
|
|
669
|
+
);
|
|
670
|
+
ToolError {
|
|
671
|
+
reason: ToolErrorReason::McpScopeRefused,
|
|
672
|
+
exc_type: "McpScopeRefused".to_string(),
|
|
673
|
+
message: "mcp.scope_refused".to_string(),
|
|
674
|
+
extra,
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
fn canonical_owner_team_key_for_event(&self) -> Option<String> {
|
|
679
|
+
let owner_team_id = self.owner_team_id.as_ref()?;
|
|
680
|
+
let state = load_runtime_state(&self.workspace).ok()?;
|
|
681
|
+
canonicalize_owner_team_id(&state, owner_team_id.as_str())
|
|
682
|
+
}
|
|
506
683
|
}
|
|
507
684
|
|
|
508
|
-
fn
|
|
509
|
-
owner_team_id
|
|
510
|
-
.
|
|
511
|
-
.
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
685
|
+
fn canonicalize_owner_team_id(state: &Value, owner_team_id: &str) -> Option<String> {
|
|
686
|
+
crate::state::projection::resolve_owner_team_id(state, owner_team_id)
|
|
687
|
+
.canonical_key()
|
|
688
|
+
.map(ToString::to_string)
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
fn requested_team_arg(args: &Value) -> Option<String> {
|
|
692
|
+
["team", "team_id", "owner_team_id", "owner_team", "target_team"]
|
|
693
|
+
.iter()
|
|
694
|
+
.find_map(|key| args.get(*key).and_then(Value::as_str).filter(|s| !s.is_empty()))
|
|
695
|
+
.map(ToString::to_string)
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
fn requested_scope_arg(args: &Value) -> Option<String> {
|
|
699
|
+
args.get("scope")
|
|
700
|
+
.and_then(Value::as_str)
|
|
701
|
+
.filter(|s| !s.is_empty())
|
|
702
|
+
.map(ToString::to_string)
|
|
703
|
+
.or_else(|| args.get("workspace").map(|_| "workspace".to_string()))
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
fn assignment_team_key(state: &Value) -> Option<String> {
|
|
707
|
+
state
|
|
708
|
+
.get("active_team_key")
|
|
709
|
+
.and_then(Value::as_str)
|
|
710
|
+
.and_then(non_empty_string)
|
|
711
|
+
.map(ToString::to_string)
|
|
518
712
|
}
|
|
519
713
|
|
|
520
714
|
fn reconcile_assigned_task(state: &mut Value, team_key: Option<&str>, task: &Value) {
|
|
@@ -601,3 +795,10 @@ fn assignment_message(task: &Value, explicit: Option<&str>) -> String {
|
|
|
601
795
|
}
|
|
602
796
|
json_dumps_default(task)
|
|
603
797
|
}
|
|
798
|
+
|
|
799
|
+
fn scope_override_name(scope: Scope) -> Option<&'static str> {
|
|
800
|
+
match scope {
|
|
801
|
+
Scope::Team => Some("team"),
|
|
802
|
+
Scope::Workspace => Some("workspace"),
|
|
803
|
+
}
|
|
804
|
+
}
|
|
@@ -152,13 +152,15 @@ pub enum ToolErrorReason {
|
|
|
152
152
|
InvalidToolArguments,
|
|
153
153
|
/// Any other exception caught (`server.py:71-72`).
|
|
154
154
|
InternalRuntimeError,
|
|
155
|
-
/// Cross-team peer addressed
|
|
155
|
+
/// Cross-team peer addressed outside the worker's spawn-time owner scope.
|
|
156
156
|
PeerNotInScope,
|
|
157
|
+
/// Worker-origin RPC attempted to widen spawn-time owner scope.
|
|
158
|
+
#[serde(rename = "mcp.scope_refused")]
|
|
159
|
+
McpScopeRefused,
|
|
157
160
|
}
|
|
158
161
|
|
|
159
|
-
/// Send scope (`tools.py:165-173`).
|
|
160
|
-
///
|
|
161
|
-
/// `mcp.scope_resolved` is one of these.
|
|
162
|
+
/// Send scope (`tools.py:165-173`). Worker-origin MCP calls resolve to the
|
|
163
|
+
/// spawn-time owner team; RPC scope arguments are refused.
|
|
162
164
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
163
165
|
#[serde(rename_all = "snake_case")]
|
|
164
166
|
pub enum Scope {
|
|
@@ -318,7 +318,6 @@ fn tool_properties(tool: McpTool) -> serde_json::Map<String, Value> {
|
|
|
318
318
|
insert_property(&mut properties, "task_id", string_property("Optional task id to associate with the message."));
|
|
319
319
|
insert_property(&mut properties, "sender", string_property("Optional sender override."));
|
|
320
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
321
|
}
|
|
323
322
|
McpTool::ReportResult => {
|
|
324
323
|
insert_property(&mut properties, "envelope", object_property("Optional full result envelope."));
|
|
@@ -386,23 +385,21 @@ fn array_property(description: &str) -> Value {
|
|
|
386
385
|
}
|
|
387
386
|
|
|
388
387
|
pub(crate) fn dispatch_tool(tools: &TeamOrchestratorTools, tool: McpTool, args: &Value) -> ToolResult {
|
|
388
|
+
if scope_ceiling_tool(tool) {
|
|
389
|
+
tools.validate_rpc_scope_args(tool.wire_name(), args)?;
|
|
390
|
+
}
|
|
389
391
|
match tool {
|
|
390
392
|
McpTool::AssignTask => tools.assign_task(args.get("task").unwrap_or(args), args.get("message").and_then(Value::as_str)),
|
|
391
393
|
McpTool::SendMessage => {
|
|
392
394
|
let target = message_target_from_value(args.get("to"));
|
|
393
395
|
let content = args.get("content").and_then(Value::as_str).unwrap_or("");
|
|
394
|
-
let scope = match args.get("scope").and_then(Value::as_str) {
|
|
395
|
-
Some("workspace") => Some(Scope::Workspace),
|
|
396
|
-
Some("team") => Some(Scope::Team),
|
|
397
|
-
_ => None,
|
|
398
|
-
};
|
|
399
396
|
let outcome = tools.send_message(
|
|
400
397
|
&target,
|
|
401
398
|
content,
|
|
402
399
|
args.get("task_id").and_then(Value::as_str),
|
|
403
400
|
args.get("sender").and_then(Value::as_str),
|
|
404
401
|
args.get("requires_ack").and_then(Value::as_bool),
|
|
405
|
-
|
|
402
|
+
None,
|
|
406
403
|
)?;
|
|
407
404
|
match outcome {
|
|
408
405
|
SendOutcome::WorkerAccepted { .. } => Ok(ToolOk {
|
|
@@ -452,6 +449,21 @@ pub(crate) fn dispatch_tool(tools: &TeamOrchestratorTools, tool: McpTool, args:
|
|
|
452
449
|
}
|
|
453
450
|
}
|
|
454
451
|
|
|
452
|
+
fn scope_ceiling_tool(tool: McpTool) -> bool {
|
|
453
|
+
matches!(
|
|
454
|
+
tool,
|
|
455
|
+
McpTool::SendMessage
|
|
456
|
+
| McpTool::ReportResult
|
|
457
|
+
| McpTool::RequestHuman
|
|
458
|
+
| McpTool::AssignTask
|
|
459
|
+
| McpTool::UpdateState
|
|
460
|
+
| McpTool::GetTeamStatus
|
|
461
|
+
| McpTool::StopAgent
|
|
462
|
+
| McpTool::ResetAgent
|
|
463
|
+
| McpTool::ForkAgent
|
|
464
|
+
)
|
|
465
|
+
}
|
|
466
|
+
|
|
455
467
|
fn message_target_from_value(value: Option<&Value>) -> MessageTarget {
|
|
456
468
|
match value {
|
|
457
469
|
Some(Value::String(s)) if s == "*" => MessageTarget::Broadcast,
|