@team-agent/installer 0.3.2 → 0.3.4
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 +196 -19
- package/crates/team-agent/src/cli/diagnose.rs +145 -11
- package/crates/team-agent/src/cli/emit.rs +287 -53
- package/crates/team-agent/src/cli/leader.rs +37 -8
- package/crates/team-agent/src/cli/mod.rs +807 -316
- package/crates/team-agent/src/cli/status_port.rs +25 -2
- 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 +57 -3
- package/crates/team-agent/src/cli/types.rs +17 -0
- package/crates/team-agent/src/compiler/tests.rs +2 -2
- package/crates/team-agent/src/compiler.rs +16 -6
- package/crates/team-agent/src/coordinator/health.rs +89 -20
- 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/tests/watch.rs +4 -2
- 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 +648 -50
- package/crates/team-agent/src/lifecycle/launch.rs +1048 -264
- package/crates/team-agent/src/lifecycle/mod.rs +3 -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 +113 -26
- package/crates/team-agent/src/lifecycle/restart/common.rs +189 -102
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +465 -25
- package/crates/team-agent/src/lifecycle/restart/remove.rs +22 -6
- package/crates/team-agent/src/lifecycle/restart/team_state.rs +19 -0
- package/crates/team-agent/src/lifecycle/restart.rs +4 -1
- package/crates/team-agent/src/lifecycle/tests/core.rs +4 -4
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +5 -5
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +39 -9
- package/crates/team-agent/src/lifecycle/types.rs +23 -0
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +326 -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 +87 -37
- package/crates/team-agent/src/messaging/mod.rs +9 -6
- package/crates/team-agent/src/messaging/results.rs +153 -16
- 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 +483 -67
- 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/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 +57 -0
- package/crates/team-agent/src/state/projection.rs +32 -23
- package/crates/team-agent/src/state/selector.rs +5 -2
- package/crates/team-agent/src/tmux_backend.rs +151 -60
- 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
|
@@ -150,6 +150,14 @@ pub(crate) fn write_team_state(
|
|
|
150
150
|
lines.push(format!("- {id}: {summary}"));
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
|
+
if let Some(notes) = team_state_notes(state).filter(|notes| !notes.is_empty()) {
|
|
154
|
+
lines.push(String::new());
|
|
155
|
+
lines.push("## Notes".to_string());
|
|
156
|
+
lines.push(String::new());
|
|
157
|
+
for note in notes {
|
|
158
|
+
lines.push(format!("- {note}"));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
153
161
|
lines.push(String::new());
|
|
154
162
|
lines.push("## Next Step".to_string());
|
|
155
163
|
lines.push(String::new());
|
|
@@ -182,6 +190,17 @@ fn team_state_tasks(spec: &YamlValue, state: &serde_json::Value) -> Vec<TeamStat
|
|
|
182
190
|
Vec::new()
|
|
183
191
|
}
|
|
184
192
|
|
|
193
|
+
fn team_state_notes(state: &serde_json::Value) -> Option<Vec<String>> {
|
|
194
|
+
Some(
|
|
195
|
+
state
|
|
196
|
+
.get("notes")?
|
|
197
|
+
.as_array()?
|
|
198
|
+
.iter()
|
|
199
|
+
.filter_map(|note| note.as_str().filter(|text| !text.is_empty()).map(str::to_string))
|
|
200
|
+
.collect(),
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
185
204
|
fn task_field_str(task: &TeamStateTask, key: &str) -> String {
|
|
186
205
|
match task {
|
|
187
206
|
TeamStateTask::Json(v) => v.get(key).and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
|
@@ -35,7 +35,10 @@ pub use agent::{reset_agent, reset_agent_with_transport, start_agent, start_agen
|
|
|
35
35
|
pub(crate) use agent::start_agent_at_paths;
|
|
36
36
|
pub(crate) use common::refresh_missing_provider_sessions;
|
|
37
37
|
pub use orchestrator::{halt_plan, plan_status};
|
|
38
|
-
pub use rebuild::{
|
|
38
|
+
pub use rebuild::{
|
|
39
|
+
restart, restart_candidates, restart_with_session_convergence_deadline, restart_with_transport,
|
|
40
|
+
select_restart_state,
|
|
41
|
+
};
|
|
39
42
|
pub use remove::{remove_agent, remove_agent_with_transport};
|
|
40
43
|
pub use selection::{classify_first_send_at, classify_restart_plan, decide_start_mode, python_type_name};
|
|
41
44
|
pub(crate) use team_state::write_team_state;
|
|
@@ -185,14 +185,14 @@ fn plan_condition_rejects_out_of_grammar() {
|
|
|
185
185
|
|
|
186
186
|
// ───────────────────────────────────────────────────────────────────────
|
|
187
187
|
// resolve_display_backend — display/backend.py
|
|
188
|
-
// 默认
|
|
188
|
+
// 默认 none;非默认非静默(non_default=true 触发 display.backend_resolved)。
|
|
189
189
|
// ───────────────────────────────────────────────────────────────────────
|
|
190
190
|
|
|
191
191
|
#[test]
|
|
192
|
-
fn
|
|
192
|
+
fn resolve_backend_defaults_to_none_when_none_requested() {
|
|
193
193
|
let r = resolve_display_backend(None, None);
|
|
194
|
-
assert_eq!(r.backend, DisplayBackend::
|
|
195
|
-
assert!(!r.non_default, "默认
|
|
194
|
+
assert_eq!(r.backend, DisplayBackend::None);
|
|
195
|
+
assert!(!r.non_default, "默认 none 不应标记 non_default");
|
|
196
196
|
}
|
|
197
197
|
|
|
198
198
|
#[test]
|
|
@@ -496,10 +496,10 @@ fn lanea_fork_window_already_exists_guard_before_spec_mutation() {
|
|
|
496
496
|
|
|
497
497
|
// ── FORK (fork-gate-error-text) [RED] + (fork-incomplete-rollback, adapter arm) — golden gate text + spec rollback
|
|
498
498
|
// Golden operations.py:329-330 raises f"{provider} does not support native session fork" when the native
|
|
499
|
-
// fork gate fails (auth_mode==compatible_api). Rust relies on adapter.
|
|
499
|
+
// fork gate fails (auth_mode==compatible_api). Rust relies on adapter.fork_plan() -> CapabilityUnsupported
|
|
500
500
|
// ("Codex:fork") (adapter.rs:310) -> a different observable. AND golden wraps the post-spec-write steps
|
|
501
501
|
// in try/except restoring the spec on ANY failure (operations.py:384-394); Rust writes the spec
|
|
502
|
-
// (launch.rs:443) then errors at adapter.
|
|
502
|
+
// (launch.rs:443) then errors at adapter.fork_plan (458-460) WITHOUT restoring it. RED on both: the message
|
|
503
503
|
// text AND the spec must be rolled back to not contain the fork agent.
|
|
504
504
|
#[test]
|
|
505
505
|
fn lanea_fork_gate_error_text_and_spec_rollback_on_adapter_arm() {
|
|
@@ -516,7 +516,7 @@ fn lanea_fork_gate_error_text_and_spec_rollback_on_adapter_arm() {
|
|
|
516
516
|
assert!(
|
|
517
517
|
!spec_text.contains("newfork"),
|
|
518
518
|
"golden operations.py:384-394: on the gate failure the spec must be ROLLED BACK; Rust writes the spec \
|
|
519
|
-
then errors at adapter.
|
|
519
|
+
then errors at adapter.fork_plan without restoring it, leaving the fork agent 'newfork' in the spec"
|
|
520
520
|
);
|
|
521
521
|
}
|
|
522
522
|
|
|
@@ -568,9 +568,9 @@ fn lanea_remove_rollback_restores_agent_health() {
|
|
|
568
568
|
// (4) restores prior state. Rust only restores the spec on the spawn_into arm (launch.rs:481); the
|
|
569
569
|
// save_runtime_state (486-487) and start_coordinator (488-493) failure arms leave the spec mutated, the
|
|
570
570
|
// already-spawned window un-killed, and the state un-rolled-back; install_mcp/cleanup_mcp are absent.
|
|
571
|
-
// The adapter.
|
|
571
|
+
// The adapter.fork_plan arm IS covered HARD above (lanea_fork_gate_error_text_and_spec_rollback_on_adapter_arm).
|
|
572
572
|
// The post-SPAWN arms need a failure-injection seam after spawn_into (codex+subscription forks past
|
|
573
|
-
// adapter.
|
|
573
|
+
// adapter.fork_plan, so the spawn succeeds and there is no in-process way to fail save/coordinator cleanly).
|
|
574
574
|
// PORTER: a Drop guard armed after the spec write, disarmed on success — kills the window, restores spec
|
|
575
575
|
// + state, runs cleanup_mcp on every post-write error arm.
|
|
576
576
|
#[test]
|
|
@@ -649,8 +649,9 @@ pub(super) fn restart_ws_two_resumable_workers() -> PathBuf {
|
|
|
649
649
|
}
|
|
650
650
|
|
|
651
651
|
// 2 [P0] — restart_with_transport must drive the REAL Route-B resume spawn: one spawn per resumable
|
|
652
|
-
// worker
|
|
653
|
-
//
|
|
652
|
+
// worker. The first resumed worker recreates the session with spawn_first; later workers may use
|
|
653
|
+
// spawn_into only after a live-session check proves that recreated session still exists. Each spawn
|
|
654
|
+
// carries the provider build_command, and the coordinator is started.
|
|
654
655
|
#[test]
|
|
655
656
|
fn restart_with_transport_spawns_resumable_workers_not_stub() {
|
|
656
657
|
let ws = restart_ws_two_resumable_workers();
|
|
@@ -665,10 +666,13 @@ fn restart_with_transport_spawns_resumable_workers_not_stub() {
|
|
|
665
666
|
"restart must spawn ONE worker per resumable agent (alpha, bravo); the rt-host-a stub returns \
|
|
666
667
|
RequirementUnmet with ZERO spawns; got {recorded:?}"
|
|
667
668
|
);
|
|
668
|
-
assert_eq!(
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
669
|
+
assert_eq!(
|
|
670
|
+
recorded[0].0, "spawn_first",
|
|
671
|
+
"with has-session=false before alpha, restart must recreate the session with spawn_first; got {recorded:?}"
|
|
672
|
+
);
|
|
673
|
+
assert_eq!(
|
|
674
|
+
recorded[1].0, "spawn_into",
|
|
675
|
+
"with has-session=true after alpha spawn, restart may add bravo with spawn_into; this is liveness-based, not index-based; got {recorded:?}"
|
|
672
676
|
);
|
|
673
677
|
assert!(
|
|
674
678
|
recorded.iter().all(|(_, argv)| argv.iter().any(|a| a == "codex")),
|
|
@@ -678,6 +682,25 @@ fn restart_with_transport_spawns_resumable_workers_not_stub() {
|
|
|
678
682
|
matches!(result, Ok(RestartReport::Restarted { coordinator_started: true, .. })),
|
|
679
683
|
"restart must reach RestartReport::Restarted with coordinator_started=true (AlreadyRunning, seeded); got {result:?}"
|
|
680
684
|
);
|
|
685
|
+
|
|
686
|
+
let ws = restart_ws_two_resumable_workers();
|
|
687
|
+
let dead_after_first = OfflineTransport::new().with_session_absent_after_spawn_first();
|
|
688
|
+
let result = restart_with_transport(&ws, false, None, &dead_after_first);
|
|
689
|
+
let recorded = dead_after_first.spawn_records();
|
|
690
|
+
assert_eq!(
|
|
691
|
+
recorded.len(),
|
|
692
|
+
1,
|
|
693
|
+
"if the session disappears after alpha, restart must stop before spawning bravo; got result={result:?} recorded={recorded:?}"
|
|
694
|
+
);
|
|
695
|
+
assert!(
|
|
696
|
+
recorded.iter().all(|(kind, _)| kind != "spawn_into"),
|
|
697
|
+
"restart must not call spawn_into/new-window for bravo against a dead server; result={result:?} recorded={recorded:?}"
|
|
698
|
+
);
|
|
699
|
+
let error = format!("{result:?}");
|
|
700
|
+
assert!(
|
|
701
|
+
error.contains("session_disappeared_after_spawn") || error.contains("provider_resume_exited"),
|
|
702
|
+
"restart must report the first resumed agent/session disappearance explicitly, not continue to a later no-server failure; result={result:?}"
|
|
703
|
+
);
|
|
681
704
|
}
|
|
682
705
|
|
|
683
706
|
// 3 [P0] — start_agent_with_transport on a non-paused agent with a session_id must spawn EXACTLY ONE
|
|
@@ -863,8 +886,8 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
|
|
|
863
886
|
);
|
|
864
887
|
assert_eq!(
|
|
865
888
|
state["display_backend"],
|
|
866
|
-
json!("
|
|
867
|
-
"golden resolve_display_backend(None, source='launch') defaults to
|
|
889
|
+
json!("none"),
|
|
890
|
+
"golden resolve_display_backend(None, source='launch') defaults to none"
|
|
868
891
|
);
|
|
869
892
|
}
|
|
870
893
|
|
|
@@ -899,6 +922,7 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
|
|
|
899
922
|
"window",
|
|
900
923
|
"mcp_config",
|
|
901
924
|
"permissions",
|
|
925
|
+
"effective_approval_policy",
|
|
902
926
|
"session_id",
|
|
903
927
|
"rollout_path",
|
|
904
928
|
"captured_at",
|
|
@@ -906,6 +930,7 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
|
|
|
906
930
|
"attribution_confidence",
|
|
907
931
|
"spawn_cwd",
|
|
908
932
|
"spawned_at",
|
|
933
|
+
"pane_id",
|
|
909
934
|
],
|
|
910
935
|
"running agent state key order must match golden launch/core.py:238-255; raw={raw}"
|
|
911
936
|
);
|
|
@@ -935,8 +960,13 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
|
|
|
935
960
|
assert!(agent["captured_at"].is_null());
|
|
936
961
|
assert!(agent["captured_via"].is_null());
|
|
937
962
|
assert!(agent["attribution_confidence"].is_null());
|
|
938
|
-
assert_eq!(agent["spawn_cwd"], json!(
|
|
963
|
+
assert_eq!(agent["spawn_cwd"], json!(team.to_string_lossy()));
|
|
939
964
|
assert_eq!(agent["spawned_at"], json!(FIXED_SPAWNED_AT));
|
|
965
|
+
assert_eq!(
|
|
966
|
+
agent["pane_id"],
|
|
967
|
+
json!("%0"),
|
|
968
|
+
"#252 RC2: launch must persist the real pane_id returned by the transport, not drop it when pane_pid is unavailable"
|
|
969
|
+
);
|
|
940
970
|
}
|
|
941
971
|
|
|
942
972
|
// Stage B2 — golden launch/core.py:171-173 writes paused workers as exactly
|
|
@@ -220,9 +220,15 @@ pub enum WorkerDisplay {
|
|
|
220
220
|
Adaptive {
|
|
221
221
|
status: DisplayStatus,
|
|
222
222
|
window: Option<WindowName>,
|
|
223
|
+
workspace_window: Option<WindowName>,
|
|
223
224
|
pane_id: Option<PaneId>,
|
|
225
|
+
pane_title: Option<String>,
|
|
224
226
|
target: Option<String>,
|
|
227
|
+
target_worker_session: Option<String>,
|
|
228
|
+
linked_session: Option<String>,
|
|
225
229
|
leader_session: Option<SessionName>,
|
|
230
|
+
display_session: Option<SessionName>,
|
|
231
|
+
fallback: Option<String>,
|
|
226
232
|
},
|
|
227
233
|
GhosttyWindow {
|
|
228
234
|
status: DisplayStatus,
|
|
@@ -415,6 +421,7 @@ pub struct LaunchReport {
|
|
|
415
421
|
pub safety: DangerousApproval,
|
|
416
422
|
/// leader receiver(attach 成功时;经 step10 leader::attach_leader_to_state)。
|
|
417
423
|
pub leader_receiver_attached: bool,
|
|
424
|
+
pub session_capture_incomplete_agents: Vec<String>,
|
|
418
425
|
}
|
|
419
426
|
|
|
420
427
|
/// 单个已起 worker(`launch` 的 `agents[]` / `started`)。
|
|
@@ -425,6 +432,10 @@ pub struct StartedAgent {
|
|
|
425
432
|
pub target: String,
|
|
426
433
|
pub session_id: Option<SessionId>,
|
|
427
434
|
pub rollout_path: Option<RolloutPath>,
|
|
435
|
+
pub pending_session_id: Option<SessionId>,
|
|
436
|
+
pub claude_config_dir: Option<PathBuf>,
|
|
437
|
+
pub provider_projects_root: Option<PathBuf>,
|
|
438
|
+
pub managed_mcp_config: bool,
|
|
428
439
|
pub display: WorkerDisplay,
|
|
429
440
|
}
|
|
430
441
|
|
|
@@ -472,6 +483,8 @@ pub enum QuickStartReport {
|
|
|
472
483
|
session_name: SessionName,
|
|
473
484
|
launch: Box<LaunchReport>,
|
|
474
485
|
next_actions: Vec<String>,
|
|
486
|
+
attach_commands: Vec<String>,
|
|
487
|
+
display_backend: String,
|
|
475
488
|
/// BUG-7: real readiness verdict. `Ready` ⇒ the wrapper completed AND the
|
|
476
489
|
/// caller already verified tool-set availability; the framework itself
|
|
477
490
|
/// never emits this without an external observable confirming worker
|
|
@@ -602,6 +615,8 @@ pub enum RestartReport {
|
|
|
602
615
|
session_name: SessionName,
|
|
603
616
|
agents: Vec<RestartedAgent>,
|
|
604
617
|
coordinator_started: bool,
|
|
618
|
+
next_actions: Vec<String>,
|
|
619
|
+
attach_commands: Vec<String>,
|
|
605
620
|
},
|
|
606
621
|
/// atomic refusal(`reason=resume_atomicity`):某 interacted worker 不可 resume
|
|
607
622
|
/// 且非 allow_fresh。**nothing created yet**,无需回滚。
|
|
@@ -610,6 +625,14 @@ pub enum RestartReport {
|
|
|
610
625
|
allow_fresh: bool,
|
|
611
626
|
error: String,
|
|
612
627
|
},
|
|
628
|
+
/// session capture did not converge before destructive restart. No teardown/spawn occurred.
|
|
629
|
+
RefusedResumeNotReady {
|
|
630
|
+
missing: Vec<AgentId>,
|
|
631
|
+
allow_fresh: bool,
|
|
632
|
+
deadline: std::time::Duration,
|
|
633
|
+
elapsed: std::time::Duration,
|
|
634
|
+
error: String,
|
|
635
|
+
},
|
|
613
636
|
/// first_send_at 损坏(`reason=invalid_first_send_at`):决策前 hard refuse。
|
|
614
637
|
RefusedInvalidFirstSendAt {
|
|
615
638
|
invalid: Vec<CorruptFirstSendAt>,
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
use std::path::Path;
|
|
2
|
+
|
|
3
|
+
use crate::lifecycle::types::{DangerousApproval, LifecycleError};
|
|
4
|
+
use crate::model::enums::{Enforcement, Provider};
|
|
5
|
+
use crate::model::ids::AgentId;
|
|
6
|
+
use crate::model::permissions::{resolve_permissions, AgentPermissionInput};
|
|
7
|
+
|
|
8
|
+
const RUNTIME_CONTRACT_SECTION: &str = r#"# Team Agent Teammate Runtime Contract
|
|
9
|
+
|
|
10
|
+
You are a teammate in a Team Agent runtime, not the user's primary assistant.
|
|
11
|
+
The user normally talks to the team lead. Plain text you write in this worker
|
|
12
|
+
session is local to this session and is not a team message.
|
|
13
|
+
|
|
14
|
+
Use Team Agent MCP tools for team-visible coordination:
|
|
15
|
+
- Send progress, blockers, permission needs, tool failures, scope changes, and
|
|
16
|
+
long-running status updates with team_orchestrator.send_message(to='leader',
|
|
17
|
+
content='<short message>').
|
|
18
|
+
- Send to another teammate by agent id when coordination is useful, or use
|
|
19
|
+
to='*' to notify every other team member. The runtime resolves only this team
|
|
20
|
+
and excludes your own worker.
|
|
21
|
+
- When the task is complete, call team_orchestrator.report_result exactly once.
|
|
22
|
+
- Do not pass sender, task_id, agent_id, schema_version, or ack fields unless
|
|
23
|
+
doing a low-level compatibility diagnostic. The MCP runtime fills protocol
|
|
24
|
+
fields from the current worker and task state.
|
|
25
|
+
|
|
26
|
+
If you are blocked or cannot continue, message the leader promptly instead of
|
|
27
|
+
waiting silently. If work takes several minutes, send a short progress update.
|
|
28
|
+
|
|
29
|
+
When any Team Agent worker hits a 500/529/rate-limit/overloaded API error,
|
|
30
|
+
slow the team down before retrying: wait 1-2 minutes, keep active workers low,
|
|
31
|
+
and avoid blind immediate retries."#;
|
|
32
|
+
|
|
33
|
+
const RESULT_ENVELOPE_OUTPUT_CONTRACT: &str =
|
|
34
|
+
"For progress or blockers, call team_orchestrator.send_message(to='leader', content='<short message>'); \
|
|
35
|
+
for teammate coordination, send to another agent id or to='*' for every other team member. \
|
|
36
|
+
do not pass sender, task_id, or requires_ack because the MCP runtime fills protocol fields. \
|
|
37
|
+
the runtime injects it into the attached Codex leader pane when the leader has run attach-leader. \
|
|
38
|
+
If no leader is attached, the tool returns a fallback/failed result instead of completion. \
|
|
39
|
+
Final completion must call team_orchestrator.report_result exactly once with a short summary \
|
|
40
|
+
and optional status/changes/tests; MCP fills schema_version, task_id, and agent_id.";
|
|
41
|
+
|
|
42
|
+
pub(crate) struct WorkerCommandAgent {
|
|
43
|
+
id: Option<String>,
|
|
44
|
+
provider: Provider,
|
|
45
|
+
role: Option<String>,
|
|
46
|
+
declared_tools: Option<Vec<String>>,
|
|
47
|
+
system_prompt_inline: Option<String>,
|
|
48
|
+
system_prompt_file: Option<String>,
|
|
49
|
+
output_contract_format: Option<String>,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
impl WorkerCommandAgent {
|
|
53
|
+
pub(crate) fn from_yaml(
|
|
54
|
+
agent: &crate::model::yaml::Value,
|
|
55
|
+
fallback_id: Option<&str>,
|
|
56
|
+
provider: Provider,
|
|
57
|
+
) -> Self {
|
|
58
|
+
let system_prompt = agent.get("system_prompt");
|
|
59
|
+
Self {
|
|
60
|
+
id: agent
|
|
61
|
+
.get("id")
|
|
62
|
+
.and_then(crate::model::yaml::Value::as_str)
|
|
63
|
+
.or(fallback_id)
|
|
64
|
+
.map(str::to_string),
|
|
65
|
+
provider,
|
|
66
|
+
role: agent
|
|
67
|
+
.get("role")
|
|
68
|
+
.and_then(crate::model::yaml::Value::as_str)
|
|
69
|
+
.map(str::to_string),
|
|
70
|
+
declared_tools: agent
|
|
71
|
+
.get("tools")
|
|
72
|
+
.and_then(crate::model::yaml::Value::as_list)
|
|
73
|
+
.map(|items| {
|
|
74
|
+
items
|
|
75
|
+
.iter()
|
|
76
|
+
.filter_map(crate::model::yaml::Value::as_str)
|
|
77
|
+
.map(str::to_string)
|
|
78
|
+
.collect()
|
|
79
|
+
}),
|
|
80
|
+
system_prompt_inline: system_prompt
|
|
81
|
+
.and_then(|prompt| prompt.get("inline"))
|
|
82
|
+
.and_then(crate::model::yaml::Value::as_str)
|
|
83
|
+
.filter(|value| !value.is_empty())
|
|
84
|
+
.map(str::to_string),
|
|
85
|
+
system_prompt_file: system_prompt
|
|
86
|
+
.and_then(|prompt| prompt.get("file"))
|
|
87
|
+
.filter(|value| value.is_truthy())
|
|
88
|
+
.and_then(crate::model::yaml::Value::as_str)
|
|
89
|
+
.map(str::to_string),
|
|
90
|
+
output_contract_format: agent
|
|
91
|
+
.get("output_contract")
|
|
92
|
+
.and_then(|contract| contract.get("format"))
|
|
93
|
+
.and_then(crate::model::yaml::Value::as_str)
|
|
94
|
+
.map(str::to_string),
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
pub(crate) fn from_json(
|
|
99
|
+
agent: &serde_json::Value,
|
|
100
|
+
fallback_id: Option<&str>,
|
|
101
|
+
provider: Provider,
|
|
102
|
+
) -> Self {
|
|
103
|
+
let system_prompt = agent.get("system_prompt");
|
|
104
|
+
Self {
|
|
105
|
+
id: agent
|
|
106
|
+
.get("id")
|
|
107
|
+
.and_then(serde_json::Value::as_str)
|
|
108
|
+
.or(fallback_id)
|
|
109
|
+
.map(str::to_string),
|
|
110
|
+
provider,
|
|
111
|
+
role: agent
|
|
112
|
+
.get("role")
|
|
113
|
+
.and_then(serde_json::Value::as_str)
|
|
114
|
+
.map(str::to_string),
|
|
115
|
+
declared_tools: agent
|
|
116
|
+
.get("tools")
|
|
117
|
+
.and_then(serde_json::Value::as_array)
|
|
118
|
+
.map(|items| {
|
|
119
|
+
items
|
|
120
|
+
.iter()
|
|
121
|
+
.filter_map(serde_json::Value::as_str)
|
|
122
|
+
.map(str::to_string)
|
|
123
|
+
.collect()
|
|
124
|
+
}),
|
|
125
|
+
system_prompt_inline: system_prompt
|
|
126
|
+
.and_then(|prompt| prompt.get("inline"))
|
|
127
|
+
.and_then(serde_json::Value::as_str)
|
|
128
|
+
.filter(|value| !value.is_empty())
|
|
129
|
+
.map(str::to_string),
|
|
130
|
+
system_prompt_file: system_prompt
|
|
131
|
+
.and_then(|prompt| prompt.get("file"))
|
|
132
|
+
.and_then(serde_json::Value::as_str)
|
|
133
|
+
.filter(|value| !value.is_empty())
|
|
134
|
+
.map(str::to_string),
|
|
135
|
+
output_contract_format: agent
|
|
136
|
+
.get("output_contract")
|
|
137
|
+
.and_then(|contract| contract.get("format"))
|
|
138
|
+
.and_then(serde_json::Value::as_str)
|
|
139
|
+
.map(str::to_string),
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
pub(crate) fn compile_worker_system_prompt(
|
|
145
|
+
agent: &WorkerCommandAgent,
|
|
146
|
+
) -> Result<String, LifecycleError> {
|
|
147
|
+
let mut chunks = vec![
|
|
148
|
+
runtime_contract_section(),
|
|
149
|
+
identity_section(agent),
|
|
150
|
+
role_body(agent)?,
|
|
151
|
+
];
|
|
152
|
+
if let Some(contract) = output_contract(agent) {
|
|
153
|
+
chunks.push(contract);
|
|
154
|
+
}
|
|
155
|
+
if let Some(notes) = permission_notes(agent)? {
|
|
156
|
+
chunks.push(notes);
|
|
157
|
+
}
|
|
158
|
+
Ok(chunks
|
|
159
|
+
.into_iter()
|
|
160
|
+
.filter(|chunk| !chunk.is_empty())
|
|
161
|
+
.collect::<Vec<_>>()
|
|
162
|
+
.join("\n\n"))
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
pub(crate) fn resolved_tool_strings_for_command(
|
|
166
|
+
agent: &WorkerCommandAgent,
|
|
167
|
+
provider: Provider,
|
|
168
|
+
safety: &DangerousApproval,
|
|
169
|
+
) -> Result<Vec<String>, LifecycleError> {
|
|
170
|
+
let mut tools: Vec<String> = resolve_agent_permissions(agent, provider)?
|
|
171
|
+
.sorted_tool_strings()
|
|
172
|
+
.into_iter()
|
|
173
|
+
.map(str::to_string)
|
|
174
|
+
.collect();
|
|
175
|
+
if safety.enabled && !tools.iter().any(|tool| tool == "dangerous_auto_approve") {
|
|
176
|
+
tools.push("dangerous_auto_approve".to_string());
|
|
177
|
+
}
|
|
178
|
+
Ok(tools)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
fn resolve_agent_permissions(
|
|
182
|
+
agent: &WorkerCommandAgent,
|
|
183
|
+
provider: Provider,
|
|
184
|
+
) -> Result<crate::model::permissions::ResolvedPermissions, LifecycleError> {
|
|
185
|
+
resolve_permissions(&AgentPermissionInput {
|
|
186
|
+
id: agent.id.as_deref().map(AgentId::new),
|
|
187
|
+
provider,
|
|
188
|
+
role: agent.role.clone(),
|
|
189
|
+
tools: agent.declared_tools.clone(),
|
|
190
|
+
})
|
|
191
|
+
.map_err(|e| LifecycleError::Compile(e.to_string()))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
fn runtime_contract_section() -> String {
|
|
195
|
+
RUNTIME_CONTRACT_SECTION.to_string()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
fn identity_section(agent: &WorkerCommandAgent) -> String {
|
|
199
|
+
format!(
|
|
200
|
+
"You are Team Agent worker `{}` with role `{}`. When asked about your role or identity, answer with this Team Agent worker identity first, not only the generic provider product identity.",
|
|
201
|
+
agent.id.as_deref().unwrap_or("unknown"),
|
|
202
|
+
agent.role.as_deref().unwrap_or("developer")
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
fn role_body(agent: &WorkerCommandAgent) -> Result<String, LifecycleError> {
|
|
207
|
+
let mut chunks = Vec::new();
|
|
208
|
+
if let Some(inline) = &agent.system_prompt_inline {
|
|
209
|
+
chunks.push(inline.clone());
|
|
210
|
+
}
|
|
211
|
+
if let Some(path) = &agent.system_prompt_file {
|
|
212
|
+
let body = std::fs::read_to_string(Path::new(path))
|
|
213
|
+
.map_err(|e| LifecycleError::Compile(format!("read system_prompt.file {path}: {e}")))?;
|
|
214
|
+
if !body.is_empty() {
|
|
215
|
+
chunks.push(body);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
Ok(chunks.join("\n\n"))
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
fn output_contract(agent: &WorkerCommandAgent) -> Option<String> {
|
|
222
|
+
(agent.output_contract_format.as_deref() == Some("result_envelope_v1"))
|
|
223
|
+
.then(|| RESULT_ENVELOPE_OUTPUT_CONTRACT.to_string())
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
fn permission_notes(agent: &WorkerCommandAgent) -> Result<Option<String>, LifecycleError> {
|
|
227
|
+
let permissions = resolve_agent_permissions(agent, agent.provider)?;
|
|
228
|
+
if !permissions.has_prompt_only {
|
|
229
|
+
return Ok(None);
|
|
230
|
+
}
|
|
231
|
+
let mut prompt_only: Vec<String> = permissions
|
|
232
|
+
.resolved_tools
|
|
233
|
+
.iter()
|
|
234
|
+
.filter(|tool| tool.enforcement == Enforcement::PromptOnly)
|
|
235
|
+
.filter_map(|tool| serde_json::to_value(tool.tool).ok())
|
|
236
|
+
.filter_map(|value| value.as_str().map(str::to_string))
|
|
237
|
+
.collect();
|
|
238
|
+
prompt_only.sort();
|
|
239
|
+
Ok(Some(format!(
|
|
240
|
+
"Permission note: these tools are prompt-only for this provider and not hard-enforced: {}",
|
|
241
|
+
prompt_only.join(", ")
|
|
242
|
+
)))
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
#[cfg(test)]
|
|
246
|
+
mod tests {
|
|
247
|
+
use super::*;
|
|
248
|
+
use crate::lifecycle::types::DangerousApprovalSource;
|
|
249
|
+
|
|
250
|
+
fn disabled_safety() -> DangerousApproval {
|
|
251
|
+
DangerousApproval {
|
|
252
|
+
enabled: false,
|
|
253
|
+
source: DangerousApprovalSource::Disabled,
|
|
254
|
+
inherited: false,
|
|
255
|
+
provider: None,
|
|
256
|
+
flag: None,
|
|
257
|
+
worker_capability_above_leader: false,
|
|
258
|
+
ancestry_binary_name: None,
|
|
259
|
+
unexpected_binary: false,
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
#[test]
|
|
264
|
+
fn empty_tools_use_role_defaults_and_aliases_resolve_before_command() {
|
|
265
|
+
let agent = WorkerCommandAgent {
|
|
266
|
+
id: Some("dev".to_string()),
|
|
267
|
+
provider: Provider::ClaudeCode,
|
|
268
|
+
role: Some("developer".to_string()),
|
|
269
|
+
declared_tools: Some(Vec::new()),
|
|
270
|
+
system_prompt_inline: None,
|
|
271
|
+
system_prompt_file: None,
|
|
272
|
+
output_contract_format: None,
|
|
273
|
+
};
|
|
274
|
+
let tools =
|
|
275
|
+
resolved_tool_strings_for_command(&agent, Provider::ClaudeCode, &disabled_safety())
|
|
276
|
+
.unwrap();
|
|
277
|
+
assert_eq!(
|
|
278
|
+
tools,
|
|
279
|
+
[
|
|
280
|
+
"execute_bash",
|
|
281
|
+
"fs_list",
|
|
282
|
+
"fs_read",
|
|
283
|
+
"fs_write",
|
|
284
|
+
"git_diff",
|
|
285
|
+
"mcp_team",
|
|
286
|
+
"provider_builtin"
|
|
287
|
+
]
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
let agent = WorkerCommandAgent {
|
|
291
|
+
declared_tools: Some(vec!["fs_*".to_string(), "@team-orchestrator".to_string()]),
|
|
292
|
+
..agent
|
|
293
|
+
};
|
|
294
|
+
let tools =
|
|
295
|
+
resolved_tool_strings_for_command(&agent, Provider::ClaudeCode, &disabled_safety())
|
|
296
|
+
.unwrap();
|
|
297
|
+
assert_eq!(tools, ["fs_list", "fs_read", "fs_write", "mcp_team"]);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
#[test]
|
|
301
|
+
fn system_prompt_uses_runtime_contract_identity_role_output_permissions_order() {
|
|
302
|
+
let agent = WorkerCommandAgent {
|
|
303
|
+
id: Some("coder".to_string()),
|
|
304
|
+
provider: Provider::Codex,
|
|
305
|
+
role: Some("Runtime Developer".to_string()),
|
|
306
|
+
declared_tools: Some(vec!["mcp_team".to_string()]),
|
|
307
|
+
system_prompt_inline: Some("Implement the assigned slice.".to_string()),
|
|
308
|
+
system_prompt_file: None,
|
|
309
|
+
output_contract_format: Some("result_envelope_v1".to_string()),
|
|
310
|
+
};
|
|
311
|
+
let prompt = compile_worker_system_prompt(&agent).unwrap();
|
|
312
|
+
let runtime = prompt
|
|
313
|
+
.find(RUNTIME_CONTRACT_SECTION.lines().next().unwrap_or(""))
|
|
314
|
+
.unwrap();
|
|
315
|
+
let identity = prompt.find("worker `coder`").unwrap();
|
|
316
|
+
let role = prompt.find("Implement the assigned slice.").unwrap();
|
|
317
|
+
let output = prompt
|
|
318
|
+
.find("Final completion must call team_orchestrator.report_result exactly once")
|
|
319
|
+
.unwrap();
|
|
320
|
+
let permissions = prompt.find("Permission note:").unwrap();
|
|
321
|
+
assert!(runtime < identity && identity < role && role < output && output < permissions);
|
|
322
|
+
let slowdown_phrase = format!("500/{}", 500 + 29);
|
|
323
|
+
assert!(prompt.contains(&slowdown_phrase));
|
|
324
|
+
assert!(prompt.contains("Runtime Developer"));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -60,6 +60,7 @@ pub(crate) fn tool_error_reason_wire(reason: ToolErrorReason) -> &'static str {
|
|
|
60
60
|
ToolErrorReason::InvalidToolArguments => "invalid_tool_arguments",
|
|
61
61
|
ToolErrorReason::InternalRuntimeError => "internal_runtime_error",
|
|
62
62
|
ToolErrorReason::PeerNotInScope => "peer_not_in_scope",
|
|
63
|
+
ToolErrorReason::McpScopeRefused => "mcp.scope_refused",
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
|