@team-agent/installer 0.5.44 → 0.5.46
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/emit.rs +44 -33
- package/crates/team-agent/src/cli/mod.rs +6 -0
- package/crates/team-agent/src/cli/named_address.rs +206 -4
- package/crates/team-agent/src/cli/send.rs +105 -2
- package/crates/team-agent/src/cli/spec.rs +1 -1
- package/crates/team-agent/src/cli/status_port.rs +70 -2
- package/crates/team-agent/src/cli/tests/run_delegation.rs +7 -4
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +14 -22
- package/crates/team-agent/src/lifecycle/restart/agent.rs +103 -27
- package/crates/team-agent/src/lifecycle/restart/common.rs +136 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +168 -0
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +7 -7
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +3 -0
- package/crates/team-agent/src/lifecycle/types.rs +3 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +10 -0
- package/crates/team-agent/src/mcp_server/tools.rs +67 -8
- package/crates/team-agent/src/model/mod.rs +6 -0
- package/crates/team-agent/src/model/name_similarity.rs +266 -0
- package/crates/team-agent/src/provider/session/capture.rs +113 -22
- package/crates/team-agent/src/provider/session_scan/codex.rs +51 -4
- package/crates/team-agent/src/tmux_backend/tests.rs +116 -10
- package/crates/team-agent/src/tmux_backend.rs +5 -16
- package/crates/team-agent/src/transport/tests/wire.rs +6 -0
- package/crates/team-agent/src/transport.rs +8 -2
- package/package.json +4 -4
|
@@ -428,6 +428,18 @@ fn restart_with_selected_team_and_transport(
|
|
|
428
428
|
let live_worker_session_deferred_teardown =
|
|
429
429
|
session_live_or_default(transport, &session_name, false)
|
|
430
430
|
&& !collect_live_agents_from_state(&state).is_empty();
|
|
431
|
+
let same_role_cohort_targets =
|
|
432
|
+
same_role_cohort_targets_from_decisions(&state, spec_workspace, &plan.decisions);
|
|
433
|
+
let pre_spawn_pane_ids = if live_worker_session_deferred_teardown {
|
|
434
|
+
transport
|
|
435
|
+
.list_targets()
|
|
436
|
+
.map_err(|error| LifecycleError::Transport(error.to_string()))?
|
|
437
|
+
.into_iter()
|
|
438
|
+
.map(|pane| pane.pane_id.as_str().to_string())
|
|
439
|
+
.collect::<std::collections::BTreeSet<_>>()
|
|
440
|
+
} else {
|
|
441
|
+
std::collections::BTreeSet::new()
|
|
442
|
+
};
|
|
431
443
|
if session_live_or_default(transport, &session_name, false)
|
|
432
444
|
&& !live_worker_session_deferred_teardown
|
|
433
445
|
{
|
|
@@ -779,6 +791,46 @@ fn restart_with_selected_team_and_transport(
|
|
|
779
791
|
);
|
|
780
792
|
}
|
|
781
793
|
// END_B5_RESTART_ISOLATION_LOOP
|
|
794
|
+
if live_worker_session_deferred_teardown {
|
|
795
|
+
let successful_agent_ids = successful_agents
|
|
796
|
+
.iter()
|
|
797
|
+
.map(|agent| agent.agent_id.as_str())
|
|
798
|
+
.collect::<std::collections::BTreeSet<_>>();
|
|
799
|
+
let old_successful_targets = same_role_cohort_targets
|
|
800
|
+
.iter()
|
|
801
|
+
.filter(|target| successful_agent_ids.contains(target.agent_id.as_str()))
|
|
802
|
+
.cloned()
|
|
803
|
+
.collect::<Vec<_>>();
|
|
804
|
+
let successful_targets =
|
|
805
|
+
same_role_cohort_targets_from_decisions(&state, spec_workspace, &successful_agents);
|
|
806
|
+
let actual_spawn_panes = actual_successful_restart_spawn_panes(
|
|
807
|
+
transport,
|
|
808
|
+
&session_name,
|
|
809
|
+
&successful_targets,
|
|
810
|
+
&pre_spawn_pane_ids,
|
|
811
|
+
)?;
|
|
812
|
+
if let Some(error) =
|
|
813
|
+
actual_restart_spawn_binding_error(&successful_targets, &actual_spawn_panes)
|
|
814
|
+
{
|
|
815
|
+
cleanup_actual_restart_spawns(transport, &actual_spawn_panes);
|
|
816
|
+
return Err(LifecycleError::RequirementUnmet(error));
|
|
817
|
+
}
|
|
818
|
+
if let Err(error) =
|
|
819
|
+
retire_expected_same_role_cohorts(transport, "restart", &old_successful_targets)
|
|
820
|
+
{
|
|
821
|
+
cleanup_actual_restart_spawns(transport, &actual_spawn_panes);
|
|
822
|
+
return Err(LifecycleError::RequirementUnmet(error));
|
|
823
|
+
}
|
|
824
|
+
if let Some(error) = same_role_cohort_exactly_one_error(
|
|
825
|
+
transport,
|
|
826
|
+
&session_name,
|
|
827
|
+
"restart",
|
|
828
|
+
&successful_targets,
|
|
829
|
+
) {
|
|
830
|
+
cleanup_actual_restart_spawns(transport, &actual_spawn_panes);
|
|
831
|
+
return Err(LifecycleError::RequirementUnmet(error));
|
|
832
|
+
}
|
|
833
|
+
}
|
|
782
834
|
let mut topology_authority_agent_ids = successful_agents
|
|
783
835
|
.iter()
|
|
784
836
|
.map(|agent| agent.agent_id.as_str().to_string())
|
|
@@ -2090,6 +2142,122 @@ fn mark_fake_harness_agent_respawned(
|
|
|
2090
2142
|
agent.insert("owner_team_id".to_string(), serde_json::json!(team_key));
|
|
2091
2143
|
}
|
|
2092
2144
|
|
|
2145
|
+
fn same_role_cohort_targets_from_decisions(
|
|
2146
|
+
state: &serde_json::Value,
|
|
2147
|
+
spec_workspace: &Path,
|
|
2148
|
+
agents: &[RestartedAgent],
|
|
2149
|
+
) -> Vec<SameRoleCohortTarget> {
|
|
2150
|
+
let mut targets = Vec::new();
|
|
2151
|
+
for restarted in agents {
|
|
2152
|
+
let agent_id = &restarted.agent_id;
|
|
2153
|
+
let raw_agent = state
|
|
2154
|
+
.get("agents")
|
|
2155
|
+
.and_then(|agents| agents.get(agent_id.as_str()));
|
|
2156
|
+
let effective_agent = raw_agent.map(|agent| {
|
|
2157
|
+
rehydrate_agent_command_context_from_spec(spec_workspace, agent_id, agent)
|
|
2158
|
+
});
|
|
2159
|
+
if effective_agent
|
|
2160
|
+
.as_ref()
|
|
2161
|
+
.and_then(|agent| agent.get("provider"))
|
|
2162
|
+
.and_then(serde_json::Value::as_str)
|
|
2163
|
+
.is_some_and(|provider| provider.eq_ignore_ascii_case("fake"))
|
|
2164
|
+
{
|
|
2165
|
+
continue;
|
|
2166
|
+
}
|
|
2167
|
+
let window = raw_agent
|
|
2168
|
+
.and_then(|agent| agent.get("window"))
|
|
2169
|
+
.and_then(serde_json::Value::as_str)
|
|
2170
|
+
.filter(|value| !value.is_empty())
|
|
2171
|
+
.unwrap_or_else(|| agent_id.as_str());
|
|
2172
|
+
if is_per_agent_cohort_window(window, agent_id) {
|
|
2173
|
+
let expected_pane_id = raw_agent
|
|
2174
|
+
.and_then(|agent| agent.get("pane_id"))
|
|
2175
|
+
.and_then(serde_json::Value::as_str)
|
|
2176
|
+
.filter(|value| !value.is_empty());
|
|
2177
|
+
targets.push(
|
|
2178
|
+
SameRoleCohortTarget::new(agent_id, window).with_expected_pane_id(expected_pane_id),
|
|
2179
|
+
);
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
targets
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
fn actual_successful_restart_spawn_panes(
|
|
2186
|
+
transport: &dyn crate::transport::Transport,
|
|
2187
|
+
session_name: &SessionName,
|
|
2188
|
+
targets: &[SameRoleCohortTarget],
|
|
2189
|
+
pre_spawn_pane_ids: &std::collections::BTreeSet<String>,
|
|
2190
|
+
) -> Result<Vec<crate::transport::PaneInfo>, LifecycleError> {
|
|
2191
|
+
let windows = targets
|
|
2192
|
+
.iter()
|
|
2193
|
+
.map(|target| target.window.as_str())
|
|
2194
|
+
.collect::<std::collections::BTreeSet<_>>();
|
|
2195
|
+
let panes = transport
|
|
2196
|
+
.list_targets()
|
|
2197
|
+
.map_err(|error| LifecycleError::Transport(error.to_string()))?
|
|
2198
|
+
.into_iter()
|
|
2199
|
+
.filter(|pane| !pre_spawn_pane_ids.contains(pane.pane_id.as_str()))
|
|
2200
|
+
.filter(|pane| pane.session.as_str() == session_name.as_str())
|
|
2201
|
+
.filter(|pane| {
|
|
2202
|
+
pane.window_name
|
|
2203
|
+
.as_ref()
|
|
2204
|
+
.is_some_and(|window| windows.contains(window.as_str()))
|
|
2205
|
+
})
|
|
2206
|
+
.filter(|pane| {
|
|
2207
|
+
transport
|
|
2208
|
+
.liveness(&pane.pane_id)
|
|
2209
|
+
.is_ok_and(|state| state == crate::transport::PaneLiveness::Live)
|
|
2210
|
+
})
|
|
2211
|
+
.collect();
|
|
2212
|
+
Ok(panes)
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
fn actual_restart_spawn_binding_error(
|
|
2216
|
+
targets: &[SameRoleCohortTarget],
|
|
2217
|
+
actual_spawn_panes: &[crate::transport::PaneInfo],
|
|
2218
|
+
) -> Option<String> {
|
|
2219
|
+
let mut mismatches = Vec::new();
|
|
2220
|
+
for target in targets {
|
|
2221
|
+
let observed = actual_spawn_panes
|
|
2222
|
+
.iter()
|
|
2223
|
+
.filter(|pane| {
|
|
2224
|
+
pane.window_name
|
|
2225
|
+
.as_ref()
|
|
2226
|
+
.is_some_and(|window| window.as_str() == target.window)
|
|
2227
|
+
})
|
|
2228
|
+
.map(|pane| pane.pane_id.as_str().to_string())
|
|
2229
|
+
.collect::<Vec<_>>();
|
|
2230
|
+
if observed.len() != 1
|
|
2231
|
+
|| target.expected_pane_id.as_deref() != observed.first().map(String::as_str)
|
|
2232
|
+
{
|
|
2233
|
+
mismatches.push(format!(
|
|
2234
|
+
"{}:window={}:expected_pane={}:actual_spawn_panes=[{}]",
|
|
2235
|
+
target.agent_id,
|
|
2236
|
+
target.window,
|
|
2237
|
+
target.expected_pane_id.as_deref().unwrap_or("<missing>"),
|
|
2238
|
+
observed.join(",")
|
|
2239
|
+
));
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
if mismatches.is_empty() {
|
|
2243
|
+
None
|
|
2244
|
+
} else {
|
|
2245
|
+
Some(format!(
|
|
2246
|
+
"restart refused: spawn identity/binding mismatch; {}",
|
|
2247
|
+
mismatches.join("; ")
|
|
2248
|
+
))
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
fn cleanup_actual_restart_spawns(
|
|
2253
|
+
transport: &dyn crate::transport::Transport,
|
|
2254
|
+
actual_spawn_panes: &[crate::transport::PaneInfo],
|
|
2255
|
+
) {
|
|
2256
|
+
for pane in actual_spawn_panes {
|
|
2257
|
+
let _ = transport.kill_pane(&pane.pane_id);
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2093
2261
|
fn write_fake_harness_spawn_argv_event(
|
|
2094
2262
|
workspace: &Path,
|
|
2095
2263
|
decision: &RestartedAgent,
|
|
@@ -402,6 +402,11 @@ case "$*" in
|
|
|
402
402
|
;;
|
|
403
403
|
esac
|
|
404
404
|
case "$*" in
|
|
405
|
+
*"new-window "*|*"new-session "*)
|
|
406
|
+
: > "$spawned_marker"
|
|
407
|
+
printf '%%9288\n'
|
|
408
|
+
exit 0
|
|
409
|
+
;;
|
|
405
410
|
*"display-message -p -t $pane #{pane_id}"*)
|
|
406
411
|
if [ -f "$killed_marker" ]; then
|
|
407
412
|
echo "can't find pane: $pane" >&2
|
|
@@ -410,11 +415,6 @@ case "$*" in
|
|
|
410
415
|
printf '%s\n' "$pane"
|
|
411
416
|
exit 0
|
|
412
417
|
;;
|
|
413
|
-
*"display-message -p -t $session:alpha #{pane_id}"*)
|
|
414
|
-
: > "$spawned_marker"
|
|
415
|
-
printf '%%9288\n'
|
|
416
|
-
exit 0
|
|
417
|
-
;;
|
|
418
418
|
*"list-windows -t $session -F #{window_name}"*)
|
|
419
419
|
exit 0
|
|
420
420
|
;;
|
|
@@ -873,8 +873,8 @@ fn reset_agent_stop_not_proven_grep_guard_hard_gate_wired() {
|
|
|
873
873
|
"reset_agent_at_paths must emit reset_agent.stop_not_proven via the writer"
|
|
874
874
|
);
|
|
875
875
|
assert!(
|
|
876
|
-
contents.contains("if !agent_is_paused
|
|
877
|
-
"gate must run
|
|
876
|
+
contents.contains("if !agent_is_paused {"),
|
|
877
|
+
"gate must run after stop for every non-paused reset, including stop.stopped=true residue"
|
|
878
878
|
);
|
|
879
879
|
assert!(
|
|
880
880
|
contents.contains("list_same_role_panes") && contents.contains("is_per_agent_window"),
|
|
@@ -744,7 +744,7 @@ impl Transport for BRealTransport {
|
|
|
744
744
|
if matches!(self.mode, BRealMode::Misowned) {
|
|
745
745
|
targets.push(pane_info("team-phaseb", "w2", "%5", 502));
|
|
746
746
|
}
|
|
747
|
-
if matches!(self.mode, BRealMode::Owned) {
|
|
747
|
+
if matches!(self.mode, BRealMode::Owned) && !self.spawns.lock().unwrap().is_empty() {
|
|
748
748
|
targets.push(pane_info("team-phaseb", "w1", "%10", 501));
|
|
749
749
|
}
|
|
750
750
|
Ok(targets)
|
|
@@ -553,6 +553,9 @@ fn normalize_value(value: Value, ctx: &mut NormalizeCtx, key: Option<&str>) -> V
|
|
|
553
553
|
let sorted = map.into_iter().collect::<BTreeMap<_, _>>();
|
|
554
554
|
let mut out = Map::new();
|
|
555
555
|
for (child_key, child) in sorted {
|
|
556
|
+
if matches!(child_key.as_str(), "model_source" | "provider_source") {
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
556
559
|
out.insert(
|
|
557
560
|
child_key.clone(),
|
|
558
561
|
normalize_value(child, ctx, Some(child_key.as_str())),
|
|
@@ -632,6 +632,9 @@ pub enum ResetAgentOutcome {
|
|
|
632
632
|
discarded_session_id: Option<SessionId>,
|
|
633
633
|
session_id: Option<SessionId>,
|
|
634
634
|
new_session_id: Option<SessionId>,
|
|
635
|
+
capture_state: String,
|
|
636
|
+
reset_proof: String,
|
|
637
|
+
weak_reset_warning: Option<String>,
|
|
635
638
|
},
|
|
636
639
|
/// 未传 discard_session → 拒绝(不丢上下文的误用保护)。
|
|
637
640
|
Refused { reason: ResetRefusal },
|
|
@@ -67,6 +67,9 @@ pub(crate) fn reset_agent(
|
|
|
67
67
|
discarded_session_id,
|
|
68
68
|
session_id,
|
|
69
69
|
new_session_id,
|
|
70
|
+
capture_state,
|
|
71
|
+
reset_proof,
|
|
72
|
+
weak_reset_warning,
|
|
70
73
|
} => Ok(ToolOk {
|
|
71
74
|
fields: object_fields(serde_json::json!({
|
|
72
75
|
"ok": true,
|
|
@@ -78,6 +81,13 @@ pub(crate) fn reset_agent(
|
|
|
78
81
|
"discarded_session_id": discarded_session_id.as_ref().map(|id| id.as_str()),
|
|
79
82
|
"session_id": session_id.as_ref().map(|id| id.as_str()),
|
|
80
83
|
"new_session_id": new_session_id.as_ref().map(|id| id.as_str()),
|
|
84
|
+
"capture_state": match capture_state.as_str() {
|
|
85
|
+
"captured" => "captured",
|
|
86
|
+
"attribution_ambiguous" => "attribution_ambiguous",
|
|
87
|
+
_ => "transcript_missing",
|
|
88
|
+
},
|
|
89
|
+
"reset_proof": if reset_proof == "weak" { "weak" } else { "strong" },
|
|
90
|
+
"weak_reset_warning": weak_reset_warning,
|
|
81
91
|
})),
|
|
82
92
|
}),
|
|
83
93
|
ResetAgentOutcome::Refused { reason } => Ok(ToolOk {
|
|
@@ -737,24 +737,83 @@ impl TeamOrchestratorTools {
|
|
|
737
737
|
{
|
|
738
738
|
return None;
|
|
739
739
|
}
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
740
|
+
// 0.5.45 naming-addressing (design §3.6, RED-2 MCP): compute
|
|
741
|
+
// the visible peers ONCE and reuse for both exact-match check
|
|
742
|
+
// AND scope-safe candidate ranking. Peers come from
|
|
743
|
+
// `get_visible_peers()` (owner-team only) so sibling teams,
|
|
744
|
+
// host registry, and workspace reverse scans cannot leak.
|
|
745
|
+
let visible_peers: Vec<String> = self
|
|
746
|
+
.get_visible_peers()
|
|
747
|
+
.map(|visible| {
|
|
748
|
+
visible
|
|
749
|
+
.peers
|
|
750
|
+
.iter()
|
|
751
|
+
.map(|peer| peer.as_str().to_string())
|
|
752
|
+
.collect()
|
|
753
|
+
})
|
|
754
|
+
.unwrap_or_default();
|
|
755
|
+
if visible_peers.iter().any(|peer| peer == target) {
|
|
756
|
+
return None;
|
|
744
757
|
}
|
|
745
|
-
let
|
|
758
|
+
let owner_team_key = owner_team
|
|
759
|
+
.as_ref()
|
|
760
|
+
.map(TeamKey::as_str)
|
|
761
|
+
.unwrap_or("")
|
|
762
|
+
.to_string();
|
|
763
|
+
// Rank the typo against owner-scope peers.
|
|
764
|
+
let ranked = {
|
|
765
|
+
use crate::model::name_similarity::{rank, Candidate};
|
|
766
|
+
let candidates: Vec<Candidate<String>> = visible_peers
|
|
767
|
+
.iter()
|
|
768
|
+
.map(|peer| Candidate {
|
|
769
|
+
match_key: peer.clone(),
|
|
770
|
+
stable_key: peer.clone(),
|
|
771
|
+
payload: peer.clone(),
|
|
772
|
+
})
|
|
773
|
+
.collect();
|
|
774
|
+
rank(target, &candidates)
|
|
775
|
+
};
|
|
776
|
+
let candidate_values: Vec<Value> = ranked
|
|
777
|
+
.iter()
|
|
778
|
+
.map(|peer| {
|
|
779
|
+
serde_json::json!({
|
|
780
|
+
"name": peer,
|
|
781
|
+
"team_key": owner_team_key,
|
|
782
|
+
"agent_id": peer,
|
|
783
|
+
"advisory": true,
|
|
784
|
+
})
|
|
785
|
+
})
|
|
786
|
+
.collect();
|
|
787
|
+
let best = ranked.first().cloned();
|
|
788
|
+
let hint = if let Some(best) = best.as_deref() {
|
|
789
|
+
format!(
|
|
790
|
+
"the requested peer is not in your owner team; did you mean `{best}`?"
|
|
791
|
+
)
|
|
792
|
+
} else {
|
|
793
|
+
"the requested peer is not part of your team; worker-origin MCP cannot widen team scope.".to_string()
|
|
794
|
+
};
|
|
746
795
|
let _ = EventLog::new(&self.workspace).write(
|
|
747
796
|
"mcp.send_message_refused",
|
|
748
797
|
serde_json::json!({
|
|
749
798
|
"reason": "peer_not_in_scope",
|
|
750
|
-
"sender_team_id":
|
|
799
|
+
"sender_team_id": owner_team_key,
|
|
751
800
|
"scope": "team",
|
|
752
|
-
"hint": hint
|
|
801
|
+
"hint": hint,
|
|
753
802
|
}),
|
|
754
803
|
);
|
|
755
804
|
let mut extra = serde_json::Map::new();
|
|
756
805
|
extra.insert("status".to_string(), Value::String("refused".to_string()));
|
|
757
|
-
extra.insert("hint".to_string(), Value::String(hint.
|
|
806
|
+
extra.insert("hint".to_string(), Value::String(hint.clone()));
|
|
807
|
+
// 0.5.45 naming-addressing (design §3.6, RED-2 MCP): additive
|
|
808
|
+
// scope-safe suggestions inside the existing ToolError.extra
|
|
809
|
+
// JSON envelope — no struct/schema change, no event log
|
|
810
|
+
// widening. Advisory only: `isError=true` and
|
|
811
|
+
// reason=peer_not_in_scope stay unchanged.
|
|
812
|
+
extra.insert("requested_name".to_string(), Value::String(target.clone()));
|
|
813
|
+
if let Some(best) = best {
|
|
814
|
+
extra.insert("suggested_name".to_string(), Value::String(best));
|
|
815
|
+
}
|
|
816
|
+
extra.insert("candidates".to_string(), Value::Array(candidate_values));
|
|
758
817
|
Some(ToolError {
|
|
759
818
|
reason: ToolErrorReason::PeerNotInScope,
|
|
760
819
|
exc_type: "PeerNotInScope".to_string(),
|
|
@@ -12,6 +12,12 @@
|
|
|
12
12
|
pub mod enums;
|
|
13
13
|
pub mod errors;
|
|
14
14
|
pub mod ids;
|
|
15
|
+
// 0.5.45 naming-addressing (design §3.2/§4.1): shared pure name
|
|
16
|
+
// similarity ranking helper. crate-private consumer set = cli/emit.rs
|
|
17
|
+
// (subcommand hint), cli/named_address.rs (typo candidates),
|
|
18
|
+
// cli/send.rs (positional adapter), mcp_server/tools.rs (owner-scoped
|
|
19
|
+
// peer suggestions). Never a routing authority — advisory only.
|
|
20
|
+
pub(crate) mod name_similarity;
|
|
15
21
|
pub mod paths;
|
|
16
22
|
pub mod permissions;
|
|
17
23
|
pub mod routing;
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
//! 0.5.45 naming-addressing (design §3.2/§4.1) — shared pure name
|
|
2
|
+
//! similarity ranking helper.
|
|
3
|
+
//!
|
|
4
|
+
//! **Pure**: no filesystem reads, no state loads, no terminal
|
|
5
|
+
//! multiplexer calls, no message dispatch. The RED-6 governance guard
|
|
6
|
+
//! scans this file for the forbidden import tokens and rejects any
|
|
7
|
+
//! occurrence.
|
|
8
|
+
//!
|
|
9
|
+
//! **Single source of truth for name-similarity ranking**: consumed by
|
|
10
|
+
//! - `cli/emit.rs::nearest_subcommand` (unknown top-level subcommand hint),
|
|
11
|
+
//! - `cli/named_address.rs` (bare/`--to-name` typo candidates),
|
|
12
|
+
//! - `cli/send.rs` positional adapter (unknown short id in selected team),
|
|
13
|
+
//! - `mcp_server/tools.rs` owner-scoped peer suggestions.
|
|
14
|
+
//!
|
|
15
|
+
//! **Never a routing authority**: the returned ranking is *advisory
|
|
16
|
+
//! only*. Callers must still exit 1 / `isError=true` / refuse the
|
|
17
|
+
//! request on their original reason; the ranked payload only
|
|
18
|
+
//! populates the additive `requested_name` / `suggested_name` /
|
|
19
|
+
//! `candidates` fields that get echoed back to the user.
|
|
20
|
+
//!
|
|
21
|
+
//! Ranking rules (design §3.2, byte-locked by RED-6):
|
|
22
|
+
//! 1. Comparison keys are trimmed and ASCII-lowercased.
|
|
23
|
+
//! 2. Candidates whose `match_key` prefixes the request outrank
|
|
24
|
+
//! lower-distance non-prefix candidates.
|
|
25
|
+
//! 3. Otherwise sort by Levenshtein distance.
|
|
26
|
+
//! 4. Threshold widens with request length: `0..=3 → 1`, `4..=6 →
|
|
27
|
+
//! 2`, longer → `3`. Nothing beyond the threshold is returned —
|
|
28
|
+
//! no "best guess for a far name".
|
|
29
|
+
//! 5. Sort order: `prefix_miss` (bool), `distance`, `stable_key`.
|
|
30
|
+
//! 6. Deduplicate on `stable_key`, then cap at 3 results.
|
|
31
|
+
//! 7. Canonical spelling comes from the candidate `name` payload
|
|
32
|
+
//! (not the lowercased comparison key) so case is preserved.
|
|
33
|
+
|
|
34
|
+
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
35
|
+
|
|
36
|
+
use std::collections::BTreeSet;
|
|
37
|
+
|
|
38
|
+
/// A candidate to rank. `T` is a caller-owned payload returned
|
|
39
|
+
/// verbatim in the ranked output — callers use it to keep whatever
|
|
40
|
+
/// side data (team_key, workspace, etc.) they need to reconstruct
|
|
41
|
+
/// their advisory JSON envelope.
|
|
42
|
+
#[derive(Debug, Clone)]
|
|
43
|
+
pub struct Candidate<T> {
|
|
44
|
+
/// Comparison key. Callers pass the raw canonical name; this
|
|
45
|
+
/// function trims + lowercases before comparing.
|
|
46
|
+
pub match_key: String,
|
|
47
|
+
/// Stable tie-break key. Typically the same as `match_key`, but
|
|
48
|
+
/// callers with cross-team or cross-workspace candidates use a
|
|
49
|
+
/// composite (`team/agent`) so ordering is deterministic across
|
|
50
|
+
/// otherwise-tied entries.
|
|
51
|
+
pub stable_key: String,
|
|
52
|
+
/// Caller-owned payload returned verbatim.
|
|
53
|
+
pub payload: T,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// Threshold for accepting a candidate given the request length.
|
|
57
|
+
/// Byte-locked by RED-6: short requests get 1, medium 2, longer 3.
|
|
58
|
+
fn threshold_for_len(len: usize) -> usize {
|
|
59
|
+
match len {
|
|
60
|
+
0..=3 => 1,
|
|
61
|
+
4..=6 => 2,
|
|
62
|
+
_ => 3,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// Rank `candidates` against `request`. Returns up to 3 payloads,
|
|
67
|
+
/// most-similar-first. `<request>` casing does not affect matching,
|
|
68
|
+
/// only comparison; the caller-supplied payload (which carries the
|
|
69
|
+
/// canonical `name`) is what surfaces to the user.
|
|
70
|
+
///
|
|
71
|
+
/// Empty return means "no acceptable candidate" — do NOT re-rank or
|
|
72
|
+
/// synthesize a best-guess elsewhere.
|
|
73
|
+
pub fn rank<T: Clone>(request: &str, candidates: &[Candidate<T>]) -> Vec<T> {
|
|
74
|
+
let request_key = normalize(request);
|
|
75
|
+
if request_key.is_empty() {
|
|
76
|
+
return Vec::new();
|
|
77
|
+
}
|
|
78
|
+
let threshold = threshold_for_len(request_key.chars().count());
|
|
79
|
+
let mut seen: BTreeSet<String> = BTreeSet::new();
|
|
80
|
+
let mut scored: Vec<(bool, usize, String, T)> = Vec::new();
|
|
81
|
+
for candidate in candidates {
|
|
82
|
+
if !seen.insert(candidate.stable_key.clone()) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
let match_key = normalize(&candidate.match_key);
|
|
86
|
+
if match_key.is_empty() {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
let distance = levenshtein(&request_key, &match_key);
|
|
90
|
+
// Prefix hit means "request is a prefix of candidate" OR
|
|
91
|
+
// "candidate is a prefix of request" — either direction
|
|
92
|
+
// beats a same-distance edit anywhere in the middle.
|
|
93
|
+
let is_prefix = match_key.starts_with(&request_key) || request_key.starts_with(&match_key);
|
|
94
|
+
// Prefix candidates skip the distance threshold (a 1-char
|
|
95
|
+
// prefix miss with distance 5 is still a legitimate
|
|
96
|
+
// completion hint); non-prefix candidates must clear the
|
|
97
|
+
// threshold.
|
|
98
|
+
if !is_prefix && distance > threshold {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
// Prefix-miss is the primary sort key; sorting bools puts
|
|
102
|
+
// false (prefix hit) before true (prefix miss).
|
|
103
|
+
scored.push((
|
|
104
|
+
!is_prefix,
|
|
105
|
+
distance,
|
|
106
|
+
candidate.stable_key.clone(),
|
|
107
|
+
candidate.payload.clone(),
|
|
108
|
+
));
|
|
109
|
+
}
|
|
110
|
+
scored.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then(a.2.cmp(&b.2)));
|
|
111
|
+
scored.into_iter().take(3).map(|(_, _, _, p)| p).collect()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// Convenience wrapper for the common case where callers only need
|
|
115
|
+
/// the single best canonical name (a `String`), not full payloads.
|
|
116
|
+
/// Returns `None` when no candidate clears the threshold.
|
|
117
|
+
pub fn best_name<'a, S: AsRef<str>>(request: &str, candidates: &'a [S]) -> Option<&'a str> {
|
|
118
|
+
let payloads: Vec<Candidate<&'a str>> = candidates
|
|
119
|
+
.iter()
|
|
120
|
+
.map(|c| {
|
|
121
|
+
let name = c.as_ref();
|
|
122
|
+
Candidate {
|
|
123
|
+
match_key: name.to_string(),
|
|
124
|
+
stable_key: name.to_string(),
|
|
125
|
+
payload: name,
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
.collect();
|
|
129
|
+
rank(request, &payloads).into_iter().next()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
fn normalize(input: &str) -> String {
|
|
133
|
+
input.trim().to_ascii_lowercase()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/// Standard Levenshtein edit distance. Pure, no dependencies.
|
|
137
|
+
/// Compared strings must already be normalized (trim + lowercase) by
|
|
138
|
+
/// the caller (`rank` does this internally).
|
|
139
|
+
pub fn levenshtein(a: &str, b: &str) -> usize {
|
|
140
|
+
let a: Vec<char> = a.chars().collect();
|
|
141
|
+
let b: Vec<char> = b.chars().collect();
|
|
142
|
+
let mut prev: Vec<usize> = (0..=b.len()).collect();
|
|
143
|
+
let mut curr = vec![0usize; b.len() + 1];
|
|
144
|
+
for (i, &ca) in a.iter().enumerate() {
|
|
145
|
+
curr[0] = i + 1;
|
|
146
|
+
for (j, &cb) in b.iter().enumerate() {
|
|
147
|
+
let cost = if ca == cb { 0 } else { 1 };
|
|
148
|
+
curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
|
|
149
|
+
}
|
|
150
|
+
std::mem::swap(&mut prev, &mut curr);
|
|
151
|
+
}
|
|
152
|
+
prev[b.len()]
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#[cfg(test)]
|
|
156
|
+
mod tests {
|
|
157
|
+
use super::*;
|
|
158
|
+
|
|
159
|
+
fn c(name: &str) -> Candidate<String> {
|
|
160
|
+
Candidate {
|
|
161
|
+
match_key: name.to_string(),
|
|
162
|
+
stable_key: name.to_string(),
|
|
163
|
+
payload: name.to_string(),
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
#[test]
|
|
168
|
+
fn levenshtein_basic() {
|
|
169
|
+
assert_eq!(levenshtein("kitten", "sitting"), 3);
|
|
170
|
+
assert_eq!(levenshtein("", ""), 0);
|
|
171
|
+
assert_eq!(levenshtein("abc", "abc"), 0);
|
|
172
|
+
assert_eq!(levenshtein("abc", "abd"), 1);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
#[test]
|
|
176
|
+
fn prefix_hit_beats_lower_distance_non_prefix() {
|
|
177
|
+
// "stat" is a prefix of "status" (distance 2) but "slat" is
|
|
178
|
+
// distance 1 (mid-word substitution). Prefix wins.
|
|
179
|
+
let out = rank("stat", &[c("slat"), c("status")]);
|
|
180
|
+
assert_eq!(out, vec!["status".to_string(), "slat".to_string()]);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
#[test]
|
|
184
|
+
fn distance_orders_non_prefix() {
|
|
185
|
+
let out = rank("btea", &[c("bota"), c("beta")]);
|
|
186
|
+
assert_eq!(out[0], "beta"); // 1 edit
|
|
187
|
+
assert_eq!(out[1], "bota"); // 2 edits
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
#[test]
|
|
191
|
+
fn stable_key_decides_tie() {
|
|
192
|
+
// Both "beta" and "bota" are distance 2 from "bata".
|
|
193
|
+
let out = rank("bata", &[c("bota"), c("beta")]);
|
|
194
|
+
assert_eq!(out, vec!["beta".to_string(), "bota".to_string()]);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
#[test]
|
|
198
|
+
fn caps_at_three() {
|
|
199
|
+
let out = rank("aaaa", &[c("aaab"), c("aaac"), c("aaad"), c("aaae")]);
|
|
200
|
+
assert_eq!(out.len(), 3);
|
|
201
|
+
assert_eq!(out, vec!["aaab", "aaac", "aaad"]);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#[test]
|
|
205
|
+
fn case_preserved_in_payload() {
|
|
206
|
+
// "BTEA" normalized matches "Beta" (distance 1); the
|
|
207
|
+
// canonical payload preserves original casing.
|
|
208
|
+
let out = rank("BTEA", &[c("Beta")]);
|
|
209
|
+
assert_eq!(out, vec!["Beta".to_string()]);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
#[test]
|
|
213
|
+
fn far_request_returns_empty() {
|
|
214
|
+
let out = rank("zzzzzz", &[c("beta")]);
|
|
215
|
+
assert!(out.is_empty());
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
#[test]
|
|
219
|
+
fn threshold_widens_with_length() {
|
|
220
|
+
assert_eq!(threshold_for_len(0), 1);
|
|
221
|
+
assert_eq!(threshold_for_len(3), 1);
|
|
222
|
+
assert_eq!(threshold_for_len(4), 2);
|
|
223
|
+
assert_eq!(threshold_for_len(6), 2);
|
|
224
|
+
assert_eq!(threshold_for_len(7), 3);
|
|
225
|
+
assert_eq!(threshold_for_len(20), 3);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
#[test]
|
|
229
|
+
fn empty_request_returns_empty() {
|
|
230
|
+
let out = rank("", &[c("beta")]);
|
|
231
|
+
assert!(out.is_empty());
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
#[test]
|
|
235
|
+
fn dedup_on_stable_key() {
|
|
236
|
+
let out = rank(
|
|
237
|
+
"beta",
|
|
238
|
+
&[
|
|
239
|
+
c("beta"),
|
|
240
|
+
Candidate {
|
|
241
|
+
match_key: "beta".to_string(),
|
|
242
|
+
stable_key: "beta".to_string(),
|
|
243
|
+
payload: "beta-dup".to_string(),
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
);
|
|
247
|
+
assert_eq!(out, vec!["beta".to_string()]);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
#[test]
|
|
251
|
+
fn best_name_convenience() {
|
|
252
|
+
assert_eq!(best_name("btea", &["beta", "bota"]), Some("beta"));
|
|
253
|
+
assert_eq!(best_name("zzzzzz", &["beta"]), None);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
#[test]
|
|
257
|
+
fn statu_still_maps_to_status() {
|
|
258
|
+
// Byte-lock the existing emit.rs behavior: `statu` → `status`
|
|
259
|
+
// with the shared helper.
|
|
260
|
+
let out = rank(
|
|
261
|
+
"statu",
|
|
262
|
+
&[c("status"), c("send"), c("start"), c("restart")],
|
|
263
|
+
);
|
|
264
|
+
assert_eq!(out[0], "status");
|
|
265
|
+
}
|
|
266
|
+
}
|