@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.
Files changed (82) hide show
  1. package/Cargo.lock +34 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/Cargo.toml +1 -1
  4. package/crates/team-agent/src/cli/adapters.rs +196 -19
  5. package/crates/team-agent/src/cli/diagnose.rs +145 -11
  6. package/crates/team-agent/src/cli/emit.rs +287 -53
  7. package/crates/team-agent/src/cli/leader.rs +37 -8
  8. package/crates/team-agent/src/cli/mod.rs +807 -316
  9. package/crates/team-agent/src/cli/status_port.rs +25 -2
  10. package/crates/team-agent/src/cli/tests/divergence.rs +1 -2
  11. package/crates/team-agent/src/cli/tests/lane_c.rs +23 -13
  12. package/crates/team-agent/src/cli/tests/main_preserved.rs +2 -0
  13. package/crates/team-agent/src/cli/tests/run_delegation.rs +57 -3
  14. package/crates/team-agent/src/cli/types.rs +17 -0
  15. package/crates/team-agent/src/compiler/tests.rs +2 -2
  16. package/crates/team-agent/src/compiler.rs +16 -6
  17. package/crates/team-agent/src/coordinator/health.rs +89 -20
  18. package/crates/team-agent/src/coordinator/mod.rs +4 -0
  19. package/crates/team-agent/src/coordinator/runtime_detectors.rs +500 -0
  20. package/crates/team-agent/src/coordinator/runtime_observation.rs +58 -0
  21. package/crates/team-agent/src/coordinator/tests/watch.rs +4 -2
  22. package/crates/team-agent/src/coordinator/tick.rs +222 -69
  23. package/crates/team-agent/src/coordinator/types.rs +15 -3
  24. package/crates/team-agent/src/db/schema.rs +37 -2
  25. package/crates/team-agent/src/diagnose/comms.rs +226 -0
  26. package/crates/team-agent/src/diagnose/mod.rs +45 -0
  27. package/crates/team-agent/src/diagnose/orphans.rs +658 -0
  28. package/crates/team-agent/src/fake_worker.rs +146 -3
  29. package/crates/team-agent/src/leader/start.rs +121 -23
  30. package/crates/team-agent/src/leader/types.rs +44 -1
  31. package/crates/team-agent/src/lib.rs +3 -0
  32. package/crates/team-agent/src/lifecycle/display.rs +648 -50
  33. package/crates/team-agent/src/lifecycle/launch.rs +1048 -264
  34. package/crates/team-agent/src/lifecycle/mod.rs +3 -0
  35. package/crates/team-agent/src/lifecycle/profile_launch.rs +810 -0
  36. package/crates/team-agent/src/lifecycle/profile_smoke.rs +522 -0
  37. package/crates/team-agent/src/lifecycle/restart/agent.rs +113 -26
  38. package/crates/team-agent/src/lifecycle/restart/common.rs +189 -102
  39. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +465 -25
  40. package/crates/team-agent/src/lifecycle/restart/remove.rs +22 -6
  41. package/crates/team-agent/src/lifecycle/restart/team_state.rs +19 -0
  42. package/crates/team-agent/src/lifecycle/restart.rs +4 -1
  43. package/crates/team-agent/src/lifecycle/tests/core.rs +4 -4
  44. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +5 -5
  45. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +39 -9
  46. package/crates/team-agent/src/lifecycle/types.rs +23 -0
  47. package/crates/team-agent/src/lifecycle/worker_command_context.rs +326 -0
  48. package/crates/team-agent/src/mcp_server/helpers.rs +1 -0
  49. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +341 -0
  50. package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +10 -0
  51. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +158 -0
  52. package/crates/team-agent/src/mcp_server/mod.rs +3 -74
  53. package/crates/team-agent/src/mcp_server/tests/scoped.rs +1 -1
  54. package/crates/team-agent/src/mcp_server/tests/send.rs +6 -5
  55. package/crates/team-agent/src/mcp_server/tools.rs +312 -111
  56. package/crates/team-agent/src/mcp_server/types.rs +6 -4
  57. package/crates/team-agent/src/mcp_server/wire.rs +19 -7
  58. package/crates/team-agent/src/message_store.rs +21 -4
  59. package/crates/team-agent/src/messaging/delivery.rs +87 -37
  60. package/crates/team-agent/src/messaging/mod.rs +9 -6
  61. package/crates/team-agent/src/messaging/results.rs +153 -16
  62. package/crates/team-agent/src/messaging/selftest.rs +199 -12
  63. package/crates/team-agent/src/messaging/send.rs +35 -3
  64. package/crates/team-agent/src/messaging/tests/runtime.rs +19 -4
  65. package/crates/team-agent/src/messaging/types.rs +11 -3
  66. package/crates/team-agent/src/os_probe.rs +119 -0
  67. package/crates/team-agent/src/packaging/migrate.rs +10 -2
  68. package/crates/team-agent/src/packaging/tests.rs +23 -0
  69. package/crates/team-agent/src/provider/adapter.rs +483 -67
  70. package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +1 -7
  71. package/crates/team-agent/src/provider/classify.rs +51 -4
  72. package/crates/team-agent/src/provider/startup_prompt.rs +94 -0
  73. package/crates/team-agent/src/provider/types.rs +47 -0
  74. package/crates/team-agent/src/session_capture.rs +616 -0
  75. package/crates/team-agent/src/state/persist.rs +57 -0
  76. package/crates/team-agent/src/state/projection.rs +32 -23
  77. package/crates/team-agent/src/state/selector.rs +5 -2
  78. package/crates/team-agent/src/tmux_backend.rs +151 -60
  79. package/crates/team-agent/src/transport/test_support.rs +9 -0
  80. package/crates/team-agent/src/transport/tests/wire.rs +4 -0
  81. package/crates/team-agent/src/transport.rs +13 -2
  82. 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::{restart, restart_candidates, restart_with_transport, select_restart_state};
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
- // 默认 adaptive;非默认非静默(non_default=true 触发 display.backend_resolved)。
188
+ // 默认 none;非默认非静默(non_default=true 触发 display.backend_resolved)。
189
189
  // ───────────────────────────────────────────────────────────────────────
190
190
 
191
191
  #[test]
192
- fn resolve_backend_defaults_to_adaptive_when_none_requested() {
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::Adaptive);
195
- assert!(!r.non_default, "默认 adaptive 不应标记 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.fork() -> CapabilityUnsupported
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.fork (458-460) WITHOUT restoring it. RED on both: the message
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.fork without restoring it, leaving the fork agent 'newfork' in the spec"
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.fork arm IS covered HARD above (lanea_fork_gate_error_text_and_spec_rollback_on_adapter_arm).
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.fork, so the spawn succeeds and there is no in-process way to fail save/coordinator cleanly).
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 (first=spawn_first, rest=spawn_into), each carrying the provider build_command, + coordinator
653
- // started. Today the stub returns RequirementUnmet with ZERO spawns -> RED at recorded.len().
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!(recorded[0].0, "spawn_first", "the first resumed worker uses new-session (spawn_first); got {recorded:?}");
669
- assert!(
670
- recorded.iter().any(|(kind, _)| kind == "spawn_into"),
671
- "subsequent resumed workers use new-window (spawn_into); got {recorded:?}"
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!("adaptive"),
867
- "golden resolve_display_backend(None, source='launch') defaults to adaptive"
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!(workspace.to_string_lossy()));
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