@team-agent/installer 0.3.3 → 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 +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/diagnose.rs +1 -1
- package/crates/team-agent/src/cli/emit.rs +1 -1
- package/crates/team-agent/src/cli/mod.rs +8 -0
- package/crates/team-agent/src/compiler/tests.rs +2 -2
- package/crates/team-agent/src/compiler.rs +1 -1
- package/crates/team-agent/src/coordinator/tests/watch.rs +4 -2
- package/crates/team-agent/src/lifecycle/display.rs +3 -3
- package/crates/team-agent/src/lifecycle/launch.rs +342 -260
- package/crates/team-agent/src/lifecycle/mod.rs +1 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +16 -5
- package/crates/team-agent/src/lifecycle/restart/common.rs +16 -23
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +31 -25
- package/crates/team-agent/src/lifecycle/tests/core.rs +4 -4
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +2 -2
- package/crates/team-agent/src/lifecycle/types.rs +4 -0
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +326 -0
- package/crates/team-agent/src/tmux_backend.rs +54 -0
- package/package.json +4 -4
|
@@ -451,10 +451,21 @@ fn write_start_agent_start_event(
|
|
|
451
451
|
let adapter = crate::provider::get_adapter(provider);
|
|
452
452
|
// Contract C / F6.4: event log must record the same context-aware argv that the
|
|
453
453
|
// actual spawn used — so the role/tools/MCP context appears in `start_agent.agent_start`.
|
|
454
|
-
let role = agent.get("role").and_then(|v| v.as_str());
|
|
455
454
|
let safety = crate::lifecycle::launch::effective_runtime_config_for_worker_spawn()?;
|
|
456
|
-
let
|
|
457
|
-
|
|
455
|
+
let command_agent =
|
|
456
|
+
crate::lifecycle::worker_command_context::WorkerCommandAgent::from_json(
|
|
457
|
+
agent,
|
|
458
|
+
Some(agent_id.as_str()),
|
|
459
|
+
provider,
|
|
460
|
+
);
|
|
461
|
+
let system_prompt =
|
|
462
|
+
crate::lifecycle::worker_command_context::compile_worker_system_prompt(&command_agent)?;
|
|
463
|
+
let tools = crate::lifecycle::worker_command_context::resolved_tool_strings_for_command(
|
|
464
|
+
&command_agent,
|
|
465
|
+
provider,
|
|
466
|
+
&safety,
|
|
467
|
+
)?;
|
|
468
|
+
let resolved_tool_refs: Vec<&str> = tools.iter().map(String::as_str).collect();
|
|
458
469
|
let mcp_config = adapter
|
|
459
470
|
.mcp_config(auth_mode)
|
|
460
471
|
.map_err(|e| LifecycleError::Provider(e.to_string()))?;
|
|
@@ -484,9 +495,9 @@ fn write_start_agent_start_event(
|
|
|
484
495
|
let context = crate::provider::ProviderCommandContext {
|
|
485
496
|
auth_mode,
|
|
486
497
|
mcp_config: Some(&mcp_config),
|
|
487
|
-
system_prompt:
|
|
498
|
+
system_prompt: Some(system_prompt.as_str()),
|
|
488
499
|
model: command_model,
|
|
489
|
-
tools: &
|
|
500
|
+
tools: &resolved_tool_refs,
|
|
490
501
|
profile_launch: Some(&profile_launch),
|
|
491
502
|
};
|
|
492
503
|
let mut plan = match session_id {
|
|
@@ -29,7 +29,6 @@ pub(super) fn spawn_agent_window(
|
|
|
29
29
|
// Contract C / F6.4: thread compiled role/tools/MCP context through restart as well —
|
|
30
30
|
// a restarted worker must come back up with the SAME callable MCP capability + role
|
|
31
31
|
// prompt as a fresh launch, else `report_result` becomes unreachable after every restart.
|
|
32
|
-
let role = agent.get("role").and_then(|v| v.as_str());
|
|
33
32
|
let detected_safety;
|
|
34
33
|
let safety = if let Some(safety) = safety {
|
|
35
34
|
safety
|
|
@@ -37,8 +36,20 @@ pub(super) fn spawn_agent_window(
|
|
|
37
36
|
detected_safety = crate::lifecycle::launch::effective_runtime_config_for_worker_spawn()?;
|
|
38
37
|
&detected_safety
|
|
39
38
|
};
|
|
40
|
-
let
|
|
41
|
-
|
|
39
|
+
let command_agent =
|
|
40
|
+
crate::lifecycle::worker_command_context::WorkerCommandAgent::from_json(
|
|
41
|
+
agent,
|
|
42
|
+
Some(agent_id.as_str()),
|
|
43
|
+
provider,
|
|
44
|
+
);
|
|
45
|
+
let system_prompt =
|
|
46
|
+
crate::lifecycle::worker_command_context::compile_worker_system_prompt(&command_agent)?;
|
|
47
|
+
let tools = crate::lifecycle::worker_command_context::resolved_tool_strings_for_command(
|
|
48
|
+
&command_agent,
|
|
49
|
+
provider,
|
|
50
|
+
safety,
|
|
51
|
+
)?;
|
|
52
|
+
let resolved_tool_refs: Vec<&str> = tools.iter().map(String::as_str).collect();
|
|
42
53
|
// owner_team_id resolution: prefer the runtime-state row's `owner_team_id` (set by
|
|
43
54
|
// launch/restart); fall back to the active team key for paths that don't write the
|
|
44
55
|
// row first (e.g. add-agent calls spawn before upserting team metadata).
|
|
@@ -78,9 +89,9 @@ pub(super) fn spawn_agent_window(
|
|
|
78
89
|
let context = crate::provider::ProviderCommandContext {
|
|
79
90
|
auth_mode,
|
|
80
91
|
mcp_config: Some(&mcp_config),
|
|
81
|
-
system_prompt:
|
|
92
|
+
system_prompt: Some(system_prompt.as_str()),
|
|
82
93
|
model: command_model,
|
|
83
|
-
tools: &
|
|
94
|
+
tools: &resolved_tool_refs,
|
|
84
95
|
profile_launch: Some(&profile_launch),
|
|
85
96
|
};
|
|
86
97
|
let mut plan = match resume_session_id {
|
|
@@ -319,24 +330,6 @@ pub(crate) fn restart_required_missing_session_agent_ids(state: &serde_json::Val
|
|
|
319
330
|
missing.sort();
|
|
320
331
|
missing
|
|
321
332
|
}
|
|
322
|
-
/// Tools list off an agent's runtime state entry (`tools: [...]`). Restart paths
|
|
323
|
-
/// don't have the full spec object, only the runtime state — so they read tools from
|
|
324
|
-
/// the state row, falling back to an empty list. Contract C requires the worker
|
|
325
|
-
/// command be built with the tool list, even on restart.
|
|
326
|
-
pub(super) fn agent_tool_strings(agent: &serde_json::Value) -> Vec<String> {
|
|
327
|
-
agent
|
|
328
|
-
.get("tools")
|
|
329
|
-
.and_then(|v| v.as_array())
|
|
330
|
-
.map(|items| {
|
|
331
|
-
items
|
|
332
|
-
.iter()
|
|
333
|
-
.filter_map(|v| v.as_str())
|
|
334
|
-
.map(str::to_string)
|
|
335
|
-
.collect()
|
|
336
|
-
})
|
|
337
|
-
.unwrap_or_default()
|
|
338
|
-
}
|
|
339
|
-
|
|
340
333
|
pub(super) fn agent_window(agent: &serde_json::Value, agent_id: &AgentId) -> String {
|
|
341
334
|
agent
|
|
342
335
|
.get("window")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
use super::*;
|
|
2
1
|
use super::common::*;
|
|
3
2
|
use super::selection::classify_restart_plan;
|
|
3
|
+
use super::*;
|
|
4
4
|
|
|
5
5
|
// ── lifecycle::restart —— 整队 Route B resume-or-fresh 重建 ──────────────────
|
|
6
6
|
|
|
@@ -101,10 +101,9 @@ pub fn restart_with_transport_with_session_convergence_deadline(
|
|
|
101
101
|
.map_err(|e| LifecycleError::TeamSelect(e.to_string()))?;
|
|
102
102
|
let mut state = selected.state;
|
|
103
103
|
crate::lifecycle::launch::ensure_owner_allowed_for_state(&state, None)?;
|
|
104
|
-
let spec_workspace = selected
|
|
105
|
-
.
|
|
106
|
-
|
|
107
|
-
.ok_or_else(|| LifecycleError::TeamSelect("active team spec workspace not found".to_string()))?;
|
|
104
|
+
let spec_workspace = selected.spec_workspace.as_ref().ok_or_else(|| {
|
|
105
|
+
LifecycleError::TeamSelect("active team spec workspace not found".to_string())
|
|
106
|
+
})?;
|
|
108
107
|
let spec = load_team_spec(spec_workspace)?;
|
|
109
108
|
let safety = crate::lifecycle::launch::effective_runtime_config(&spec)?;
|
|
110
109
|
let mut convergence = converge_missing_provider_sessions(
|
|
@@ -236,10 +235,20 @@ pub fn restart_with_transport_with_session_convergence_deadline(
|
|
|
236
235
|
crate::state::projection::save_team_scoped_state(&selected.run_workspace, &state)
|
|
237
236
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
238
237
|
let coordinator_started = start_coordinator_for_workspace(&selected.run_workspace)?;
|
|
238
|
+
let attach_commands = crate::tmux_backend::attach_commands_for_windows(
|
|
239
|
+
&selected.run_workspace,
|
|
240
|
+
&session_name,
|
|
241
|
+
plan.decisions
|
|
242
|
+
.iter()
|
|
243
|
+
.map(|decision| decision.agent_id.as_str()),
|
|
244
|
+
);
|
|
245
|
+
let next_actions = attach_commands.clone();
|
|
239
246
|
Ok(RestartReport::Restarted {
|
|
240
247
|
session_name,
|
|
241
248
|
agents: plan.decisions,
|
|
242
249
|
coordinator_started,
|
|
250
|
+
next_actions,
|
|
251
|
+
attach_commands,
|
|
243
252
|
})
|
|
244
253
|
}
|
|
245
254
|
|
|
@@ -456,10 +465,7 @@ fn mark_leader_receiver_rebind_required(state: &mut serde_json::Value, session_n
|
|
|
456
465
|
.and_then(|v| v.as_str())
|
|
457
466
|
.is_some_and(|status| status == "attached")
|
|
458
467
|
{
|
|
459
|
-
receiver.insert(
|
|
460
|
-
"status".to_string(),
|
|
461
|
-
serde_json::json!("rebind_required"),
|
|
462
|
-
);
|
|
468
|
+
receiver.insert("status".to_string(), serde_json::json!("rebind_required"));
|
|
463
469
|
}
|
|
464
470
|
}
|
|
465
471
|
|
|
@@ -520,11 +526,7 @@ fn mark_agent_respawned(
|
|
|
520
526
|
if let Some(pane_pid) = pane_pid {
|
|
521
527
|
agent.insert("pane_pid".to_string(), serde_json::json!(pane_pid));
|
|
522
528
|
}
|
|
523
|
-
crate::lifecycle::launch::persist_command_plan_state(
|
|
524
|
-
agent,
|
|
525
|
-
&spawn.plan,
|
|
526
|
-
&spawn.profile_launch,
|
|
527
|
-
);
|
|
529
|
+
crate::lifecycle::launch::persist_command_plan_state(agent, &spawn.plan, &spawn.profile_launch);
|
|
528
530
|
persist_effective_approval_policy_for_restart(agent, safety);
|
|
529
531
|
agent.remove("startup_prompts");
|
|
530
532
|
agent.remove("startup_prompt_status");
|
|
@@ -583,8 +585,7 @@ fn write_restart_resume_decision_event(
|
|
|
583
585
|
|
|
584
586
|
let path = workspace.join(".team").join("logs").join("events.jsonl");
|
|
585
587
|
if let Some(parent) = path.parent() {
|
|
586
|
-
std::fs::create_dir_all(parent)
|
|
587
|
-
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
588
|
+
std::fs::create_dir_all(parent).map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
588
589
|
}
|
|
589
590
|
let mut event = serde_json::json!({
|
|
590
591
|
"ts": chrono::Utc::now().to_rfc3339(),
|
|
@@ -615,8 +616,8 @@ fn write_restart_resume_decision_event(
|
|
|
615
616
|
}
|
|
616
617
|
}
|
|
617
618
|
}
|
|
618
|
-
let line =
|
|
619
|
-
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
619
|
+
let line =
|
|
620
|
+
serde_json::to_string(&event).map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
620
621
|
let mut file = std::fs::OpenOptions::new()
|
|
621
622
|
.create(true)
|
|
622
623
|
.append(true)
|
|
@@ -667,7 +668,10 @@ pub fn select_restart_state(
|
|
|
667
668
|
.get("active_team_key")
|
|
668
669
|
.and_then(serde_json::Value::as_str)
|
|
669
670
|
.filter(|s| !s.is_empty())
|
|
670
|
-
.map_or_else(
|
|
671
|
+
.map_or_else(
|
|
672
|
+
|| crate::state::projection::team_state_key(&selected),
|
|
673
|
+
str::to_string,
|
|
674
|
+
);
|
|
671
675
|
Ok(restart_candidate_from_state(workspace, &key, &selected))
|
|
672
676
|
}
|
|
673
677
|
|
|
@@ -733,12 +737,14 @@ fn restart_candidate_has_context(state: &serde_json::Value) -> bool {
|
|
|
733
737
|
.and_then(serde_json::Value::as_object)
|
|
734
738
|
.is_some_and(|agents| {
|
|
735
739
|
agents.values().any(|agent| {
|
|
736
|
-
["session_id", "rollout_path", "first_send_at"]
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
740
|
+
["session_id", "rollout_path", "first_send_at"]
|
|
741
|
+
.iter()
|
|
742
|
+
.any(|key| {
|
|
743
|
+
agent
|
|
744
|
+
.get(*key)
|
|
745
|
+
.and_then(serde_json::Value::as_str)
|
|
746
|
+
.is_some_and(|s| !s.is_empty())
|
|
747
|
+
})
|
|
742
748
|
})
|
|
743
749
|
})
|
|
744
750
|
}
|
|
@@ -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]
|
|
@@ -886,8 +886,8 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
|
|
|
886
886
|
);
|
|
887
887
|
assert_eq!(
|
|
888
888
|
state["display_backend"],
|
|
889
|
-
json!("
|
|
890
|
-
"golden resolve_display_backend(None, source='launch') defaults to
|
|
889
|
+
json!("none"),
|
|
890
|
+
"golden resolve_display_backend(None, source='launch') defaults to none"
|
|
891
891
|
);
|
|
892
892
|
}
|
|
893
893
|
|
|
@@ -483,6 +483,8 @@ pub enum QuickStartReport {
|
|
|
483
483
|
session_name: SessionName,
|
|
484
484
|
launch: Box<LaunchReport>,
|
|
485
485
|
next_actions: Vec<String>,
|
|
486
|
+
attach_commands: Vec<String>,
|
|
487
|
+
display_backend: String,
|
|
486
488
|
/// BUG-7: real readiness verdict. `Ready` ⇒ the wrapper completed AND the
|
|
487
489
|
/// caller already verified tool-set availability; the framework itself
|
|
488
490
|
/// never emits this without an external observable confirming worker
|
|
@@ -613,6 +615,8 @@ pub enum RestartReport {
|
|
|
613
615
|
session_name: SessionName,
|
|
614
616
|
agents: Vec<RestartedAgent>,
|
|
615
617
|
coordinator_started: bool,
|
|
618
|
+
next_actions: Vec<String>,
|
|
619
|
+
attach_commands: Vec<String>,
|
|
616
620
|
},
|
|
617
621
|
/// atomic refusal(`reason=resume_atomicity`):某 interacted worker 不可 resume
|
|
618
622
|
/// 且非 allow_fresh。**nothing created yet**,无需回滚。
|
|
@@ -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
|
+
}
|
|
@@ -318,6 +318,60 @@ pub(crate) fn socket_name_for_workspace(workspace: &Path) -> String {
|
|
|
318
318
|
format!("ta-{:012x}", hasher.finish() & 0xffff_ffff_ffff)
|
|
319
319
|
}
|
|
320
320
|
|
|
321
|
+
pub(crate) fn socket_path_for_workspace(workspace: &Path) -> Option<PathBuf> {
|
|
322
|
+
let socket_name = socket_name_for_workspace(workspace);
|
|
323
|
+
let roots = tmux_socket_roots();
|
|
324
|
+
for root in &roots {
|
|
325
|
+
let root = root.canonicalize().unwrap_or_else(|_| root.clone());
|
|
326
|
+
let candidate = root.join(&socket_name);
|
|
327
|
+
if candidate.exists() {
|
|
328
|
+
return Some(candidate.canonicalize().unwrap_or(candidate));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
let uid = unsafe { libc::geteuid() };
|
|
332
|
+
let default_root = PathBuf::from(format!("/tmp/tmux-{uid}"));
|
|
333
|
+
let default_root = default_root
|
|
334
|
+
.canonicalize()
|
|
335
|
+
.unwrap_or(default_root);
|
|
336
|
+
Some(default_root.join(socket_name))
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
pub(crate) fn attach_command_for_workspace(
|
|
340
|
+
workspace: &Path,
|
|
341
|
+
session_name: &SessionName,
|
|
342
|
+
window_name: &str,
|
|
343
|
+
) -> Option<String> {
|
|
344
|
+
let socket_path = socket_path_for_workspace(workspace)?;
|
|
345
|
+
Some(format!(
|
|
346
|
+
"tmux -S {} attach -t {}:{}",
|
|
347
|
+
socket_path.display(),
|
|
348
|
+
session_name.as_str(),
|
|
349
|
+
window_name
|
|
350
|
+
))
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
pub(crate) fn attach_commands_for_windows<'a>(
|
|
354
|
+
workspace: &Path,
|
|
355
|
+
session_name: &SessionName,
|
|
356
|
+
window_names: impl IntoIterator<Item = &'a str>,
|
|
357
|
+
) -> Vec<String> {
|
|
358
|
+
window_names
|
|
359
|
+
.into_iter()
|
|
360
|
+
.filter_map(|window_name| attach_command_for_workspace(workspace, session_name, window_name))
|
|
361
|
+
.collect()
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
fn tmux_socket_roots() -> Vec<PathBuf> {
|
|
365
|
+
let uid = unsafe { libc::geteuid() };
|
|
366
|
+
let mut roots = vec![PathBuf::from(format!("/tmp/tmux-{uid}"))];
|
|
367
|
+
if let Some(tmpdir) = std::env::var_os("TMPDIR") {
|
|
368
|
+
roots.push(PathBuf::from(tmpdir).join(format!("tmux-{uid}")));
|
|
369
|
+
}
|
|
370
|
+
roots.sort();
|
|
371
|
+
roots.dedup();
|
|
372
|
+
roots
|
|
373
|
+
}
|
|
374
|
+
|
|
321
375
|
pub(crate) fn socket_name_from_tmux_env() -> Option<String> {
|
|
322
376
|
let tmux = std::env::var("TMUX")
|
|
323
377
|
.ok()
|